Ali2206 commited on
Commit
682caaf
·
1 Parent(s): 1f68b7c

Initial CPS-API deployment with TxAgent integration

Browse files
.gitignore ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ venv/
26
+ env/
27
+ ENV/
28
+ env.bak/
29
+ venv.bak/
30
+
31
+ # Environment variables
32
+ .env
33
+ .env.local
34
+ .env.development.local
35
+ .env.test.local
36
+ .env.production.local
37
+
38
+ # IDE
39
+ .vscode/
40
+ .idea/
41
+ *.swp
42
+ *.swo
43
+ *~
44
+
45
+ # OS
46
+ .DS_Store
47
+ .DS_Store?
48
+ ._*
49
+ .Spotlight-V100
50
+ .Trashes
51
+ ehthumbs.db
52
+ Thumbs.db
53
+
54
+ # Logs
55
+ *.log
56
+ logs/
57
+
58
+ # Database
59
+ *.db
60
+ *.sqlite3
61
+
62
+ # Temporary files
63
+ tmp/
64
+ temp/
65
+ *.tmp
66
+
67
+ # Output files
68
+ output/
69
+ uploads/
70
+ *.pdf
71
+
72
+ # Node modules (if any)
73
+ node_modules/
74
+
75
+ # Coverage reports
76
+ htmlcov/
77
+ .coverage
78
+ .coverage.*
79
+ coverage.xml
80
+ *.cover
81
+ .hypothesis/
82
+ .pytest_cache/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Python image
2
+ FROM python:3.10
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system packages (LaTeX, fonts, and compiler tools)
8
+ RUN apt-get update && apt-get install -y \
9
+ latexmk \
10
+ texlive-latex-recommended \
11
+ texlive-fonts-recommended \
12
+ texlive-latex-extra \
13
+ texlive-xetex \
14
+ lmodern \
15
+ fontconfig \
16
+ build-essential \
17
+ && apt-get clean \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Install Python dependencies
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # Copy app code
25
+ COPY . .
26
+
27
+ # Expose FastAPI port (Hugging Face Spaces uses port 7860)
28
+ EXPOSE 7860
29
+
30
+ # Start FastAPI app with proper configuration for HF Spaces
31
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers"]
README.md CHANGED
@@ -1,10 +1,74 @@
1
  ---
2
- title: Cps Api Tx
3
- emoji: 🌖
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CPS API
3
+ emoji: 📈
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # CPS-API - Clinical Patient System API
11
+
12
+ A FastAPI-based backend for clinical patient management with AI integration (TxAgent).
13
+
14
+ ## Features
15
+
16
+ - Patient management
17
+ - Authentication & authorization
18
+ - AI-powered clinical assistance (TxAgent)
19
+ - Voice transcription and synthesis
20
+ - PDF generation
21
+ - FHIR integration
22
+ - Appointment scheduling
23
+
24
+ ## Local Development
25
+
26
+ ```bash
27
+ # Install dependencies
28
+ pip install -r requirements.txt
29
+
30
+ # Run the server
31
+ python -m uvicorn app:app --reload --host 0.0.0.0 --port 8000
32
+ ```
33
+
34
+ ## Deployment to Hugging Face Spaces
35
+
36
+ This API is configured for deployment on Hugging Face Spaces.
37
+
38
+ ### Environment Variables
39
+
40
+ Set these in your Hugging Face Space settings:
41
+
42
+ ```bash
43
+ # MongoDB connection
44
+ MONGODB_URL=your_mongodb_connection_string
45
+
46
+ # JWT settings
47
+ SECRET_KEY=your_secret_key
48
+ ALGORITHM=HS256
49
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
50
+
51
+ # TxAgent configuration
52
+ TXAGENT_MODE=cloud
53
+ TXAGENT_CLOUD_URL=https://rocketfarmstudios-txagent-api.hf.space
54
+ TXAGENT_LOCAL_ENABLED=false
55
+
56
+ # CORS settings
57
+ ALLOWED_ORIGINS=https://your-frontend-domain.com
58
+ ```
59
+
60
+ ### API Endpoints
61
+
62
+ - **Health Check**: `GET /`
63
+ - **Documentation**: `GET /docs`
64
+ - **Authentication**: `/auth/*`
65
+ - **Patients**: `/patients/*`
66
+ - **Appointments**: `/appointments/*`
67
+ - **TxAgent AI**: `/txagent/*`
68
+
69
+ ## Architecture
70
+
71
+ - **Backend**: FastAPI + MongoDB
72
+ - **AI Integration**: TxAgent with fallback (local/cloud)
73
+ - **Authentication**: JWT tokens
74
+ - **Documentation**: Auto-generated OpenAPI/Swagger
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This makes this directory a Python package
api/__init__.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ import logging
3
+
4
+ # Configure logging
5
+ logging.basicConfig(
6
+ level=logging.INFO,
7
+ format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
8
+ )
9
+ logger = logging.getLogger(__name__)
10
+
11
+ def get_router():
12
+ router = APIRouter()
13
+
14
+ # Import sub-modules from the routes directory
15
+ from .routes.auth import auth
16
+ from .routes.patients import patients
17
+ from .routes.pdf import pdf
18
+ from .routes.appointments import router as appointments
19
+ from .routes.messaging import router as messaging
20
+ from .routes.txagent import router as txagent
21
+
22
+ # Include sub-routers with correct prefixes
23
+ router.include_router(patients, tags=["patients"]) # Remove prefix since routes already have /patients
24
+ router.include_router(auth, prefix="/auth", tags=["auth"])
25
+ router.include_router(pdf, prefix="/patients", tags=["pdf"]) # Keep prefix for PDF routes
26
+ router.include_router(appointments, tags=["appointments"])
27
+ router.include_router(messaging, tags=["messaging"])
28
+ router.include_router(txagent, tags=["txagent"])
29
+
30
+ return router
31
+
32
+ # Export the router
33
+ api_router = get_router()
api/routes.py ADDED
@@ -0,0 +1 @@
 
 
1
+ #
api/routes/__init__.py ADDED
File without changes
api/routes/appointments.py ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
2
+ from typing import List, Optional
3
+ from datetime import date, time, datetime, timedelta
4
+ from bson import ObjectId
5
+ from motor.motor_asyncio import AsyncIOMotorClient
6
+
7
+ from core.security import get_current_user
8
+ from db.mongo import db, patients_collection
9
+ from models.schemas import (
10
+ AppointmentCreate, AppointmentUpdate, AppointmentResponse, AppointmentListResponse,
11
+ AppointmentStatus, AppointmentType, DoctorAvailabilityCreate, DoctorAvailabilityUpdate,
12
+ DoctorAvailabilityResponse, AvailableSlotsResponse, AppointmentSlot, DoctorListResponse
13
+ )
14
+
15
+ router = APIRouter(prefix="/appointments", tags=["appointments"])
16
+
17
+ # --- HELPER FUNCTIONS ---
18
+ def is_valid_object_id(id_str: str) -> bool:
19
+ try:
20
+ ObjectId(id_str)
21
+ return True
22
+ except:
23
+ return False
24
+
25
+ def get_weekday_from_date(appointment_date: date) -> int:
26
+ """Convert date to weekday (0=Monday, 6=Sunday)"""
27
+ return appointment_date.weekday()
28
+
29
+ def generate_time_slots(start_time: time, end_time: time, duration: int = 30) -> List[time]:
30
+ """Generate time slots between start and end time"""
31
+ slots = []
32
+ current_time = datetime.combine(date.today(), start_time)
33
+ end_datetime = datetime.combine(date.today(), end_time)
34
+
35
+ while current_time < end_datetime:
36
+ slots.append(current_time.time())
37
+ current_time += timedelta(minutes=duration)
38
+
39
+ return slots
40
+
41
+ # --- PATIENT ASSIGNMENT ENDPOINT ---
42
+
43
+ @router.post("/assign-patient", status_code=status.HTTP_200_OK)
44
+ async def assign_patient_to_doctor(
45
+ patient_id: str,
46
+ doctor_id: str,
47
+ current_user: dict = Depends(get_current_user),
48
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
49
+ ):
50
+ """Manually assign a patient to a doctor"""
51
+ # Only doctors and admins can assign patients
52
+ if not (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or
53
+ ('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
54
+ raise HTTPException(
55
+ status_code=status.HTTP_403_FORBIDDEN,
56
+ detail="Only doctors and admins can assign patients"
57
+ )
58
+
59
+ # Validate ObjectIds
60
+ if not is_valid_object_id(patient_id) or not is_valid_object_id(doctor_id):
61
+ raise HTTPException(
62
+ status_code=status.HTTP_400_BAD_REQUEST,
63
+ detail="Invalid patient_id or doctor_id"
64
+ )
65
+
66
+ # Check if doctor exists and is a doctor
67
+ doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
68
+ if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
69
+ raise HTTPException(
70
+ status_code=status.HTTP_404_NOT_FOUND,
71
+ detail="Doctor not found"
72
+ )
73
+
74
+ # Check if patient exists
75
+ patient = await db_client.users.find_one({"_id": ObjectId(patient_id)})
76
+ if not patient:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_404_NOT_FOUND,
79
+ detail="Patient not found"
80
+ )
81
+
82
+ try:
83
+ # Check if patient already exists in patients collection
84
+ existing_patient = await patients_collection.find_one({
85
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"])
86
+ })
87
+
88
+ if existing_patient:
89
+ # Update existing patient to assign to this doctor
90
+ await patients_collection.update_one(
91
+ {"_id": existing_patient["_id"]},
92
+ {
93
+ "$set": {
94
+ "assigned_doctor_id": str(doctor_id), # Convert to string
95
+ "updated_at": datetime.now()
96
+ }
97
+ }
98
+ )
99
+ message = f"Patient {patient.get('full_name', 'Unknown')} reassigned to doctor {doctor.get('full_name', 'Unknown')}"
100
+ else:
101
+ # Create new patient record in patients collection
102
+ patient_doc = {
103
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"]),
104
+ "full_name": patient.get("full_name", ""),
105
+ "date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
106
+ "gender": patient.get("gender") or "unknown", # Provide default
107
+ "address": patient.get("address"),
108
+ "city": patient.get("city"),
109
+ "state": patient.get("state"),
110
+ "postal_code": patient.get("postal_code"),
111
+ "country": patient.get("country"),
112
+ "national_id": patient.get("national_id"),
113
+ "blood_type": patient.get("blood_type"),
114
+ "allergies": patient.get("allergies", []),
115
+ "chronic_conditions": patient.get("chronic_conditions", []),
116
+ "medications": patient.get("medications", []),
117
+ "emergency_contact_name": patient.get("emergency_contact_name"),
118
+ "emergency_contact_phone": patient.get("emergency_contact_phone"),
119
+ "insurance_provider": patient.get("insurance_provider"),
120
+ "insurance_policy_number": patient.get("insurance_policy_number"),
121
+ "notes": patient.get("notes", []),
122
+ "source": "manual", # Manually assigned
123
+ "status": "active",
124
+ "assigned_doctor_id": str(doctor_id), # Convert to string
125
+ "registration_date": datetime.now(),
126
+ "created_at": datetime.now(),
127
+ "updated_at": datetime.now()
128
+ }
129
+ await patients_collection.insert_one(patient_doc)
130
+ message = f"Patient {patient.get('full_name', 'Unknown')} assigned to doctor {doctor.get('full_name', 'Unknown')}"
131
+
132
+ print(f"✅ {message}")
133
+ return {"message": message, "success": True}
134
+
135
+ except Exception as e:
136
+ print(f"❌ Error assigning patient to doctor: {str(e)}")
137
+ raise HTTPException(
138
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
139
+ detail=f"Failed to assign patient to doctor: {str(e)}"
140
+ )
141
+
142
+ # --- DOCTOR LIST ENDPOINT (MUST BE BEFORE PARAMETERIZED ROUTES) ---
143
+
144
+ @router.get("/doctors", response_model=List[DoctorListResponse])
145
+ async def get_doctors(
146
+ specialty: Optional[str] = Query(None),
147
+ current_user: dict = Depends(get_current_user),
148
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
149
+ ):
150
+ """Get list of available doctors"""
151
+ # Build filter - handle both "role" (singular) and "roles" (array) fields
152
+ filter_query = {
153
+ "$or": [
154
+ {"roles": {"$in": ["doctor"]}},
155
+ {"role": "doctor"}
156
+ ]
157
+ }
158
+ if specialty:
159
+ filter_query["$and"] = [
160
+ {"$or": [{"roles": {"$in": ["doctor"]}}, {"role": "doctor"}]},
161
+ {"specialty": {"$regex": specialty, "$options": "i"}}
162
+ ]
163
+
164
+ # Get doctors
165
+ cursor = db_client.users.find(filter_query)
166
+ doctors = await cursor.to_list(length=None)
167
+
168
+ return [
169
+ DoctorListResponse(
170
+ id=str(doctor["_id"]),
171
+ full_name=doctor['full_name'],
172
+ specialty=doctor.get('specialty', ''),
173
+ license_number=doctor.get('license_number', ''),
174
+ email=doctor['email'],
175
+ phone=doctor.get('phone')
176
+ )
177
+ for doctor in doctors
178
+ ]
179
+
180
+ # --- DOCTOR AVAILABILITY ENDPOINTS ---
181
+
182
+ @router.post("/availability", response_model=DoctorAvailabilityResponse, status_code=status.HTTP_201_CREATED)
183
+ async def create_doctor_availability(
184
+ availability_data: DoctorAvailabilityCreate,
185
+ current_user: dict = Depends(get_current_user),
186
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
187
+ ):
188
+ """Create doctor availability"""
189
+ if (current_user.get('role') != 'doctor' and 'doctor' not in current_user.get('roles', [])) and 'admin' not in current_user.get('roles', []):
190
+ raise HTTPException(
191
+ status_code=status.HTTP_403_FORBIDDEN,
192
+ detail="Only doctors can set their availability"
193
+ )
194
+
195
+ # Check if doctor exists
196
+ doctor = await db_client.users.find_one({"_id": ObjectId(availability_data.doctor_id)})
197
+ if not doctor:
198
+ raise HTTPException(
199
+ status_code=status.HTTP_404_NOT_FOUND,
200
+ detail="Doctor not found"
201
+ )
202
+
203
+ # Check if doctor is setting their own availability or admin is setting it
204
+ if (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])) and availability_data.doctor_id != current_user["_id"]:
205
+ raise HTTPException(
206
+ status_code=status.HTTP_403_FORBIDDEN,
207
+ detail="You can only set your own availability"
208
+ )
209
+
210
+ # Check if availability already exists for this day
211
+ existing = await db_client.doctor_availability.find_one({
212
+ "doctor_id": ObjectId(availability_data.doctor_id),
213
+ "day_of_week": availability_data.day_of_week
214
+ })
215
+
216
+ if existing:
217
+ raise HTTPException(
218
+ status_code=status.HTTP_409_CONFLICT,
219
+ detail="Availability already exists for this day"
220
+ )
221
+
222
+ # Create availability
223
+ availability_doc = {
224
+ "doctor_id": ObjectId(availability_data.doctor_id),
225
+ "day_of_week": availability_data.day_of_week,
226
+ "start_time": availability_data.start_time,
227
+ "end_time": availability_data.end_time,
228
+ "is_available": availability_data.is_available
229
+ }
230
+
231
+ result = await db_client.doctor_availability.insert_one(availability_doc)
232
+ availability_doc["_id"] = result.inserted_id
233
+
234
+ return DoctorAvailabilityResponse(
235
+ id=str(availability_doc["_id"]),
236
+ doctor_id=availability_data.doctor_id,
237
+ doctor_name=doctor['full_name'],
238
+ day_of_week=availability_doc["day_of_week"],
239
+ start_time=availability_doc["start_time"],
240
+ end_time=availability_doc["end_time"],
241
+ is_available=availability_doc["is_available"]
242
+ )
243
+
244
+ @router.get("/availability/{doctor_id}", response_model=List[DoctorAvailabilityResponse])
245
+ async def get_doctor_availability(
246
+ doctor_id: str,
247
+ current_user: dict = Depends(get_current_user),
248
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
249
+ ):
250
+ """Get doctor availability"""
251
+ if not is_valid_object_id(doctor_id):
252
+ raise HTTPException(
253
+ status_code=status.HTTP_400_BAD_REQUEST,
254
+ detail="Invalid doctor ID"
255
+ )
256
+
257
+ # Check if doctor exists
258
+ doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
259
+ if not doctor:
260
+ raise HTTPException(
261
+ status_code=status.HTTP_404_NOT_FOUND,
262
+ detail="Doctor not found"
263
+ )
264
+
265
+ # Get availability
266
+ cursor = db_client.doctor_availability.find({"doctor_id": ObjectId(doctor_id)})
267
+ availabilities = await cursor.to_list(length=None)
268
+
269
+ return [
270
+ DoctorAvailabilityResponse(
271
+ id=str(avail["_id"]),
272
+ doctor_id=doctor_id,
273
+ doctor_name=doctor['full_name'],
274
+ day_of_week=avail["day_of_week"],
275
+ start_time=avail["start_time"],
276
+ end_time=avail["end_time"],
277
+ is_available=avail["is_available"]
278
+ )
279
+ for avail in availabilities
280
+ ]
281
+
282
+ # --- AVAILABLE SLOTS ENDPOINTS ---
283
+
284
+ @router.get("/slots/{doctor_id}/{date}", response_model=AvailableSlotsResponse)
285
+ async def get_available_slots(
286
+ doctor_id: str,
287
+ date: date,
288
+ current_user: dict = Depends(get_current_user),
289
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
290
+ ):
291
+ """Get available appointment slots for a doctor on a specific date"""
292
+ if not is_valid_object_id(doctor_id):
293
+ raise HTTPException(
294
+ status_code=status.HTTP_400_BAD_REQUEST,
295
+ detail="Invalid doctor ID"
296
+ )
297
+
298
+ # Check if doctor exists
299
+ doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
300
+ if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
301
+ raise HTTPException(
302
+ status_code=status.HTTP_404_NOT_FOUND,
303
+ detail="Doctor not found"
304
+ )
305
+
306
+ # Get doctor availability for this day
307
+ weekday = get_weekday_from_date(date)
308
+ availability = await db_client.doctor_availability.find_one({
309
+ "doctor_id": ObjectId(doctor_id),
310
+ "day_of_week": weekday,
311
+ "is_available": True
312
+ })
313
+
314
+ if not availability:
315
+ return AvailableSlotsResponse(
316
+ doctor_id=doctor_id,
317
+ doctor_name=doctor['full_name'],
318
+ specialty=doctor.get('specialty', ''),
319
+ date=date,
320
+ slots=[]
321
+ )
322
+
323
+ # Generate time slots
324
+ time_slots = generate_time_slots(availability["start_time"], availability["end_time"])
325
+
326
+ # Get existing appointments for this date
327
+ existing_appointments = await db_client.appointments.find({
328
+ "doctor_id": ObjectId(doctor_id),
329
+ "date": date,
330
+ "status": {"$in": ["pending", "confirmed"]}
331
+ }).to_list(length=None)
332
+
333
+ booked_times = {apt["time"] for apt in existing_appointments}
334
+
335
+ # Create slot responses
336
+ slots = []
337
+ for slot_time in time_slots:
338
+ is_available = slot_time not in booked_times
339
+ appointment_id = None
340
+ if not is_available:
341
+ # Find the appointment ID for this slot
342
+ appointment = next((apt for apt in existing_appointments if apt["time"] == slot_time), None)
343
+ appointment_id = str(appointment["_id"]) if appointment else None
344
+
345
+ slots.append(AppointmentSlot(
346
+ date=date,
347
+ time=slot_time,
348
+ is_available=is_available,
349
+ appointment_id=appointment_id
350
+ ))
351
+
352
+ return AvailableSlotsResponse(
353
+ doctor_id=doctor_id,
354
+ doctor_name=doctor['full_name'],
355
+ specialty=doctor.get('specialty', ''),
356
+ date=date,
357
+ slots=slots
358
+ )
359
+
360
+ # --- APPOINTMENT ENDPOINTS ---
361
+
362
+ @router.post("/", response_model=AppointmentResponse, status_code=status.HTTP_201_CREATED)
363
+ async def create_appointment(
364
+ appointment_data: AppointmentCreate,
365
+ current_user: dict = Depends(get_current_user),
366
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
367
+ ):
368
+ """Create a new appointment"""
369
+ print(f"🔍 Creating appointment with data: {appointment_data}")
370
+ print(f"🔍 Current user: {current_user}")
371
+ # Check if user is a patient
372
+ if 'patient' not in current_user.get('roles', []) and current_user.get('role') != 'patient':
373
+ raise HTTPException(
374
+ status_code=status.HTTP_403_FORBIDDEN,
375
+ detail="Only patients can create appointments"
376
+ )
377
+
378
+ # Validate ObjectIds
379
+ if not is_valid_object_id(appointment_data.patient_id) or not is_valid_object_id(appointment_data.doctor_id):
380
+ raise HTTPException(
381
+ status_code=status.HTTP_400_BAD_REQUEST,
382
+ detail="Invalid patient_id or doctor_id"
383
+ )
384
+
385
+ # Check if patient exists and matches current user
386
+ patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
387
+ if not patient:
388
+ raise HTTPException(
389
+ status_code=status.HTTP_404_NOT_FOUND,
390
+ detail="Patient not found"
391
+ )
392
+
393
+ if patient['email'] != current_user['email']:
394
+ raise HTTPException(
395
+ status_code=status.HTTP_403_FORBIDDEN,
396
+ detail="You can only create appointments for yourself"
397
+ )
398
+
399
+ # Check if doctor exists and is a doctor
400
+ doctor = await db_client.users.find_one({"_id": ObjectId(appointment_data.doctor_id)})
401
+ if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
402
+ raise HTTPException(
403
+ status_code=status.HTTP_404_NOT_FOUND,
404
+ detail="Doctor not found"
405
+ )
406
+
407
+ # Convert string date and time to proper objects
408
+ try:
409
+ appointment_date = datetime.strptime(appointment_data.date, '%Y-%m-%d').date()
410
+ appointment_time = datetime.strptime(appointment_data.time, '%H:%M:%S').time()
411
+ # Convert date to datetime for MongoDB compatibility
412
+ appointment_datetime = datetime.combine(appointment_date, appointment_time)
413
+ except ValueError:
414
+ raise HTTPException(
415
+ status_code=status.HTTP_400_BAD_REQUEST,
416
+ detail="Invalid date or time format. Use YYYY-MM-DD for date and HH:MM:SS for time"
417
+ )
418
+
419
+ # Check if appointment date is in the future
420
+ if appointment_datetime <= datetime.now():
421
+ raise HTTPException(
422
+ status_code=status.HTTP_400_BAD_REQUEST,
423
+ detail="Appointment must be scheduled for a future date and time"
424
+ )
425
+
426
+ # Check if slot is available
427
+ existing_appointment = await db_client.appointments.find_one({
428
+ "doctor_id": ObjectId(appointment_data.doctor_id),
429
+ "date": appointment_data.date,
430
+ "time": appointment_data.time,
431
+ "status": {"$in": ["pending", "confirmed"]}
432
+ })
433
+
434
+ if existing_appointment:
435
+ raise HTTPException(
436
+ status_code=status.HTTP_409_CONFLICT,
437
+ detail="This time slot is already booked"
438
+ )
439
+
440
+ # Create appointment
441
+ appointment_doc = {
442
+ "patient_id": ObjectId(appointment_data.patient_id),
443
+ "doctor_id": ObjectId(appointment_data.doctor_id),
444
+ "date": appointment_data.date, # Store as string
445
+ "time": appointment_data.time, # Store as string
446
+ "datetime": appointment_datetime, # Store full datetime for easier querying
447
+ "type": appointment_data.type,
448
+ "status": AppointmentStatus.PENDING,
449
+ "reason": appointment_data.reason,
450
+ "notes": appointment_data.notes,
451
+ "duration": appointment_data.duration,
452
+ "created_at": datetime.now(),
453
+ "updated_at": datetime.now()
454
+ }
455
+
456
+ result = await db_client.appointments.insert_one(appointment_doc)
457
+ appointment_doc["_id"] = result.inserted_id
458
+
459
+ # If appointment is created with confirmed status, assign patient to doctor
460
+ if appointment_data.status == "confirmed":
461
+ try:
462
+ # Get patient details from users collection
463
+ patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
464
+ if patient:
465
+ # Check if patient already exists in patients collection
466
+ existing_patient = await patients_collection.find_one({
467
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"])
468
+ })
469
+
470
+ if existing_patient:
471
+ # Update existing patient to assign to this doctor
472
+ await patients_collection.update_one(
473
+ {"_id": existing_patient["_id"]},
474
+ {
475
+ "$set": {
476
+ "assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
477
+ "updated_at": datetime.now()
478
+ }
479
+ }
480
+ )
481
+ else:
482
+ # Create new patient record in patients collection
483
+ patient_doc = {
484
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"]),
485
+ "full_name": patient.get("full_name", ""),
486
+ "date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
487
+ "gender": patient.get("gender") or "unknown", # Provide default
488
+ "address": patient.get("address"),
489
+ "city": patient.get("city"),
490
+ "state": patient.get("state"),
491
+ "postal_code": patient.get("postal_code"),
492
+ "country": patient.get("country"),
493
+ "national_id": patient.get("national_id"),
494
+ "blood_type": patient.get("blood_type"),
495
+ "allergies": patient.get("allergies", []),
496
+ "chronic_conditions": patient.get("chronic_conditions", []),
497
+ "medications": patient.get("medications", []),
498
+ "emergency_contact_name": patient.get("emergency_contact_name"),
499
+ "emergency_contact_phone": patient.get("emergency_contact_phone"),
500
+ "insurance_provider": patient.get("insurance_provider"),
501
+ "insurance_policy_number": patient.get("insurance_policy_number"),
502
+ "notes": patient.get("notes", []),
503
+ "source": "direct", # Patient came through appointment booking
504
+ "status": "active",
505
+ "assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
506
+ "registration_date": datetime.now(),
507
+ "created_at": datetime.now(),
508
+ "updated_at": datetime.now()
509
+ }
510
+ await patients_collection.insert_one(patient_doc)
511
+
512
+ print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment_data.doctor_id}")
513
+ except Exception as e:
514
+ print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
515
+ # Don't fail the appointment creation if patient assignment fails
516
+
517
+ # Return appointment with patient and doctor names
518
+ return AppointmentResponse(
519
+ id=str(appointment_doc["_id"]),
520
+ patient_id=appointment_data.patient_id,
521
+ doctor_id=appointment_data.doctor_id,
522
+ patient_name=patient['full_name'],
523
+ doctor_name=doctor['full_name'],
524
+ date=appointment_date, # Convert back to date object
525
+ time=appointment_time, # Convert back to time object
526
+ type=appointment_doc["type"],
527
+ status=appointment_doc["status"],
528
+ reason=appointment_doc["reason"],
529
+ notes=appointment_doc["notes"],
530
+ duration=appointment_doc["duration"],
531
+ created_at=appointment_doc["created_at"],
532
+ updated_at=appointment_doc["updated_at"]
533
+ )
534
+
535
+ @router.get("/", response_model=AppointmentListResponse)
536
+ async def get_appointments(
537
+ page: int = Query(1, ge=1),
538
+ limit: int = Query(10, ge=1, le=100),
539
+ status_filter: Optional[AppointmentStatus] = Query(None),
540
+ date_from: Optional[date] = Query(None),
541
+ date_to: Optional[date] = Query(None),
542
+ current_user: dict = Depends(get_current_user),
543
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
544
+ ):
545
+ """Get appointments based on user role"""
546
+ skip = (page - 1) * limit
547
+
548
+ # Build filter based on user role
549
+ if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
550
+ # Patients see their own appointments
551
+ filter_query = {"patient_id": ObjectId(current_user["_id"])}
552
+ elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
553
+ # Doctors see appointments assigned to them
554
+ filter_query = {"doctor_id": ObjectId(current_user["_id"])}
555
+ elif 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
556
+ # Admins see all appointments
557
+ filter_query = {}
558
+ else:
559
+ raise HTTPException(
560
+ status_code=status.HTTP_403_FORBIDDEN,
561
+ detail="Insufficient permissions"
562
+ )
563
+
564
+ # Add status filter
565
+ if status_filter:
566
+ filter_query["status"] = status_filter
567
+
568
+ # Add date range filter
569
+ if date_from or date_to:
570
+ date_filter = {}
571
+ if date_from:
572
+ date_filter["$gte"] = date_from
573
+ if date_to:
574
+ date_filter["$lte"] = date_to
575
+ filter_query["date"] = date_filter
576
+
577
+ # Get appointments
578
+ cursor = db_client.appointments.find(filter_query).skip(skip).limit(limit).sort("date", -1)
579
+ appointments = await cursor.to_list(length=limit)
580
+
581
+ print(f"🔍 Found {len(appointments)} appointments")
582
+ for i, apt in enumerate(appointments):
583
+ print(f"🔍 Appointment {i}: {apt}")
584
+
585
+ # Get total count
586
+ total = await db_client.appointments.count_documents(filter_query)
587
+
588
+ # Get patient and doctor names
589
+ appointment_responses = []
590
+ for apt in appointments:
591
+ patient = await db_client.users.find_one({"_id": apt["patient_id"]})
592
+ doctor = await db_client.users.find_one({"_id": apt["doctor_id"]})
593
+
594
+ # Convert string date/time back to objects for response
595
+ apt_date = datetime.strptime(apt["date"], '%Y-%m-%d').date() if isinstance(apt["date"], str) else apt["date"]
596
+ apt_time = datetime.strptime(apt["time"], '%H:%M:%S').time() if isinstance(apt["time"], str) else apt["time"]
597
+
598
+ appointment_responses.append(AppointmentResponse(
599
+ id=str(apt["_id"]),
600
+ patient_id=str(apt["patient_id"]),
601
+ doctor_id=str(apt["doctor_id"]),
602
+ patient_name=patient['full_name'] if patient else "Unknown Patient",
603
+ doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
604
+ date=apt_date,
605
+ time=apt_time,
606
+ type=apt.get("type", "consultation"), # Default to consultation if missing
607
+ status=apt.get("status", "pending"), # Default to pending if missing
608
+ reason=apt.get("reason"),
609
+ notes=apt.get("notes"),
610
+ duration=apt.get("duration", 30),
611
+ created_at=apt.get("created_at", datetime.now()),
612
+ updated_at=apt.get("updated_at", datetime.now())
613
+ ))
614
+
615
+ return AppointmentListResponse(
616
+ appointments=appointment_responses,
617
+ total=total,
618
+ page=page,
619
+ limit=limit
620
+ )
621
+
622
+ @router.get("/{appointment_id}", response_model=AppointmentResponse)
623
+ async def get_appointment(
624
+ appointment_id: str,
625
+ current_user: dict = Depends(get_current_user),
626
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
627
+ ):
628
+ """Get a specific appointment"""
629
+ if not is_valid_object_id(appointment_id):
630
+ raise HTTPException(
631
+ status_code=status.HTTP_400_BAD_REQUEST,
632
+ detail="Invalid appointment ID"
633
+ )
634
+
635
+ appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
636
+ if not appointment:
637
+ raise HTTPException(
638
+ status_code=status.HTTP_404_NOT_FOUND,
639
+ detail="Appointment not found"
640
+ )
641
+
642
+ # Check permissions
643
+ if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
644
+ if appointment["patient_id"] != ObjectId(current_user["_id"]):
645
+ raise HTTPException(
646
+ status_code=status.HTTP_403_FORBIDDEN,
647
+ detail="You can only view your own appointments"
648
+ )
649
+ elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
650
+ if appointment["doctor_id"] != ObjectId(current_user["_id"]):
651
+ raise HTTPException(
652
+ status_code=status.HTTP_403_FORBIDDEN,
653
+ detail="You can only view appointments assigned to you"
654
+ )
655
+ elif (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])):
656
+ raise HTTPException(
657
+ status_code=status.HTTP_403_FORBIDDEN,
658
+ detail="Insufficient permissions"
659
+ )
660
+
661
+ # Get patient and doctor names
662
+ patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
663
+ doctor = await db_client.users.find_one({"_id": appointment["doctor_id"]})
664
+
665
+ # Convert string date/time back to objects for response
666
+ apt_date = datetime.strptime(appointment["date"], '%Y-%m-%d').date() if isinstance(appointment["date"], str) else appointment["date"]
667
+ apt_time = datetime.strptime(appointment["time"], '%H:%M:%S').time() if isinstance(appointment["time"], str) else appointment["time"]
668
+
669
+ return AppointmentResponse(
670
+ id=str(appointment["_id"]),
671
+ patient_id=str(appointment["patient_id"]),
672
+ doctor_id=str(appointment["doctor_id"]),
673
+ patient_name=patient['full_name'] if patient else "Unknown Patient",
674
+ doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
675
+ date=apt_date,
676
+ time=apt_time,
677
+ type=appointment.get("type", "consultation"), # Default to consultation if missing
678
+ status=appointment.get("status", "pending"), # Default to pending if missing
679
+ reason=appointment.get("reason"),
680
+ notes=appointment.get("notes"),
681
+ duration=appointment.get("duration", 30),
682
+ created_at=appointment.get("created_at", datetime.now()),
683
+ updated_at=appointment.get("updated_at", datetime.now())
684
+ )
685
+
686
+ @router.put("/{appointment_id}", response_model=AppointmentResponse)
687
+ async def update_appointment(
688
+ appointment_id: str,
689
+ appointment_data: AppointmentUpdate,
690
+ current_user: dict = Depends(get_current_user),
691
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
692
+ ):
693
+ """Update an appointment"""
694
+ if not is_valid_object_id(appointment_id):
695
+ raise HTTPException(
696
+ status_code=status.HTTP_400_BAD_REQUEST,
697
+ detail="Invalid appointment ID"
698
+ )
699
+
700
+ appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
701
+ if not appointment:
702
+ raise HTTPException(
703
+ status_code=status.HTTP_404_NOT_FOUND,
704
+ detail="Appointment not found"
705
+ )
706
+
707
+ # Check permissions
708
+ can_update = False
709
+ if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
710
+ can_update = True
711
+ elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
712
+ if appointment["doctor_id"] == ObjectId(current_user["_id"]):
713
+ can_update = True
714
+ elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
715
+ if appointment["patient_id"] == ObjectId(current_user["_id"]):
716
+ # Patients can only update certain fields
717
+ can_update = True
718
+
719
+ if not can_update:
720
+ raise HTTPException(
721
+ status_code=status.HTTP_403_FORBIDDEN,
722
+ detail="You can only update appointments you're involved with"
723
+ )
724
+
725
+ # Build update data
726
+ update_data = {"updated_at": datetime.now()}
727
+
728
+ if appointment_data.date is not None:
729
+ try:
730
+ # Store as string for MongoDB compatibility
731
+ update_data["date"] = appointment_data.date
732
+ except ValueError:
733
+ raise HTTPException(
734
+ status_code=status.HTTP_400_BAD_REQUEST,
735
+ detail="Invalid date format. Use YYYY-MM-DD"
736
+ )
737
+ if appointment_data.time is not None:
738
+ try:
739
+ # Store as string for MongoDB compatibility
740
+ update_data["time"] = appointment_data.time
741
+ except ValueError:
742
+ raise HTTPException(
743
+ status_code=status.HTTP_400_BAD_REQUEST,
744
+ detail="Invalid time format. Use HH:MM:SS"
745
+ )
746
+ if appointment_data.type is not None:
747
+ update_data["type"] = appointment_data.type
748
+ if appointment_data.reason is not None:
749
+ update_data["reason"] = appointment_data.reason
750
+ if appointment_data.notes is not None:
751
+ update_data["notes"] = appointment_data.notes
752
+ if appointment_data.duration is not None:
753
+ update_data["duration"] = appointment_data.duration
754
+
755
+ # Only doctors and admins can update status
756
+ if appointment_data.status is not None and (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or ('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
757
+ update_data["status"] = appointment_data.status
758
+
759
+ # Check for conflicts if date/time is being updated
760
+ if appointment_data.date is not None or appointment_data.time is not None:
761
+ new_date = update_data.get("date") or appointment["date"]
762
+ new_time = update_data.get("time") or appointment["time"]
763
+
764
+ existing_appointment = await db_client.appointments.find_one({
765
+ "_id": {"$ne": ObjectId(appointment_id)},
766
+ "doctor_id": appointment["doctor_id"],
767
+ "date": new_date,
768
+ "time": new_time,
769
+ "status": {"$in": ["pending", "confirmed"]}
770
+ })
771
+
772
+ if existing_appointment:
773
+ raise HTTPException(
774
+ status_code=status.HTTP_409_CONFLICT,
775
+ detail="This time slot is already booked"
776
+ )
777
+
778
+ # Update appointment
779
+ await db_client.appointments.update_one(
780
+ {"_id": ObjectId(appointment_id)},
781
+ {"$set": update_data}
782
+ )
783
+
784
+ # If appointment status is being changed to confirmed, assign patient to doctor
785
+ if appointment_data.status == "confirmed":
786
+ try:
787
+ # Get patient details from users collection
788
+ patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
789
+ if patient:
790
+ # Check if patient already exists in patients collection
791
+ existing_patient = await patients_collection.find_one({
792
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"])
793
+ })
794
+
795
+ if existing_patient:
796
+ # Update existing patient to assign to this doctor
797
+ await patients_collection.update_one(
798
+ {"_id": existing_patient["_id"]},
799
+ {
800
+ "$set": {
801
+ "assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
802
+ "updated_at": datetime.now()
803
+ }
804
+ }
805
+ )
806
+ else:
807
+ # Create new patient record in patients collection
808
+ patient_doc = {
809
+ "fhir_id": patient.get("fhir_id") or str(patient["_id"]),
810
+ "full_name": patient.get("full_name", ""),
811
+ "date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
812
+ "gender": patient.get("gender") or "unknown", # Provide default
813
+ "address": patient.get("address"),
814
+ "city": patient.get("city"),
815
+ "state": patient.get("state"),
816
+ "postal_code": patient.get("postal_code"),
817
+ "country": patient.get("country"),
818
+ "national_id": patient.get("national_id"),
819
+ "blood_type": patient.get("blood_type"),
820
+ "allergies": patient.get("allergies", []),
821
+ "chronic_conditions": patient.get("chronic_conditions", []),
822
+ "medications": patient.get("medications", []),
823
+ "emergency_contact_name": patient.get("emergency_contact_name"),
824
+ "emergency_contact_phone": patient.get("emergency_contact_phone"),
825
+ "insurance_provider": patient.get("insurance_provider"),
826
+ "insurance_policy_number": patient.get("insurance_policy_number"),
827
+ "notes": patient.get("notes", []),
828
+ "source": "direct", # Patient came through appointment booking
829
+ "status": "active",
830
+ "assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
831
+ "registration_date": datetime.now(),
832
+ "created_at": datetime.now(),
833
+ "updated_at": datetime.now()
834
+ }
835
+ await patients_collection.insert_one(patient_doc)
836
+
837
+ print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment['doctor_id']}")
838
+ except Exception as e:
839
+ print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
840
+ # Don't fail the appointment update if patient assignment fails
841
+
842
+ # Get updated appointment
843
+ updated_appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
844
+
845
+ # Get patient and doctor names
846
+ patient = await db_client.users.find_one({"_id": updated_appointment["patient_id"]})
847
+ doctor = await db_client.users.find_one({"_id": updated_appointment["doctor_id"]})
848
+
849
+ # Convert string date/time back to objects for response
850
+ apt_date = datetime.strptime(updated_appointment["date"], '%Y-%m-%d').date() if isinstance(updated_appointment["date"], str) else updated_appointment["date"]
851
+ apt_time = datetime.strptime(updated_appointment["time"], '%H:%M:%S').time() if isinstance(updated_appointment["time"], str) else updated_appointment["time"]
852
+
853
+ return AppointmentResponse(
854
+ id=str(updated_appointment["_id"]),
855
+ patient_id=str(updated_appointment["patient_id"]),
856
+ doctor_id=str(updated_appointment["doctor_id"]),
857
+ patient_name=patient['full_name'] if patient else "Unknown Patient",
858
+ doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
859
+ date=apt_date,
860
+ time=apt_time,
861
+ type=updated_appointment.get("type", "consultation"), # Default to consultation if missing
862
+ status=updated_appointment.get("status", "pending"), # Default to pending if missing
863
+ reason=updated_appointment.get("reason"),
864
+ notes=updated_appointment.get("notes"),
865
+ duration=updated_appointment.get("duration", 30),
866
+ created_at=updated_appointment.get("created_at", datetime.now()),
867
+ updated_at=updated_appointment.get("updated_at", datetime.now())
868
+ )
869
+
870
+ @router.delete("/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
871
+ async def delete_appointment(
872
+ appointment_id: str,
873
+ current_user: dict = Depends(get_current_user),
874
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
875
+ ):
876
+ """Delete an appointment"""
877
+ if not is_valid_object_id(appointment_id):
878
+ raise HTTPException(
879
+ status_code=status.HTTP_400_BAD_REQUEST,
880
+ detail="Invalid appointment ID"
881
+ )
882
+
883
+ appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
884
+ if not appointment:
885
+ raise HTTPException(
886
+ status_code=status.HTTP_404_NOT_FOUND,
887
+ detail="Appointment not found"
888
+ )
889
+
890
+ # Check permissions
891
+ can_delete = False
892
+ if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
893
+ can_delete = True
894
+ elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
895
+ if appointment["patient_id"] == ObjectId(current_user["_id"]):
896
+ can_delete = True
897
+
898
+ if not can_delete:
899
+ raise HTTPException(
900
+ status_code=status.HTTP_403_FORBIDDEN,
901
+ detail="You can only delete your own appointments"
902
+ )
903
+
904
+ await db_client.appointments.delete_one({"_id": ObjectId(appointment_id)})
api/routes/auth.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, status, Request, Query
2
+ from typing import Optional
3
+ from fastapi.security import OAuth2PasswordRequestForm
4
+ from db.mongo import users_collection
5
+ from core.security import hash_password, verify_password, create_access_token, get_current_user
6
+ from models.schemas import (
7
+ SignupForm,
8
+ TokenResponse,
9
+ DoctorCreate,
10
+ AdminCreate,
11
+ ProfileUpdate,
12
+ PasswordChange,
13
+ AdminUserUpdate,
14
+ AdminPasswordReset,
15
+ )
16
+ from datetime import datetime
17
+ import logging
18
+
19
+ # Configure logging
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
23
+ )
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter()
27
+
28
+ @router.post("/signup", status_code=status.HTTP_201_CREATED)
29
+ async def signup(data: SignupForm):
30
+ """
31
+ Patient registration endpoint - only patients can register through signup
32
+ Doctors and admins must be created by existing admins
33
+ """
34
+ logger.info(f"Patient signup attempt for email: {data.email}")
35
+ logger.info(f"Received signup data: {data.dict()}")
36
+ email = data.email.lower().strip()
37
+ existing = await users_collection.find_one({"email": email})
38
+ if existing:
39
+ logger.warning(f"Signup failed: Email already exists: {email}")
40
+ raise HTTPException(
41
+ status_code=status.HTTP_409_CONFLICT,
42
+ detail="Email already exists"
43
+ )
44
+
45
+ hashed_pw = hash_password(data.password)
46
+ user_doc = {
47
+ "email": email,
48
+ "full_name": data.full_name.strip(),
49
+ "password": hashed_pw,
50
+ "roles": ["patient"], # Only patients can register through signup
51
+ "created_at": datetime.utcnow().isoformat(),
52
+ "updated_at": datetime.utcnow().isoformat(),
53
+ "device_token": "" # Default empty device token for patients
54
+ }
55
+
56
+ try:
57
+ result = await users_collection.insert_one(user_doc)
58
+ logger.info(f"User created successfully: {email}")
59
+ return {
60
+ "status": "success",
61
+ "id": str(result.inserted_id),
62
+ "email": email
63
+ }
64
+ except Exception as e:
65
+ logger.error(f"Failed to create user {email}: {str(e)}")
66
+ raise HTTPException(
67
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
68
+ detail=f"Failed to create user: {str(e)}"
69
+ )
70
+
71
+ @router.post("/admin/doctors", status_code=status.HTTP_201_CREATED)
72
+ async def create_doctor(
73
+ data: DoctorCreate,
74
+ current_user: dict = Depends(get_current_user)
75
+ ):
76
+ """
77
+ Create doctor account - only admins can create doctor accounts
78
+ """
79
+ logger.info(f"Doctor creation attempt by {current_user.get('email')}")
80
+ if 'admin' not in current_user.get('roles', []):
81
+ logger.warning(f"Unauthorized doctor creation attempt by {current_user.get('email')}")
82
+ raise HTTPException(
83
+ status_code=status.HTTP_403_FORBIDDEN,
84
+ detail="Only admins can create doctor accounts"
85
+ )
86
+
87
+ email = data.email.lower().strip()
88
+ existing = await users_collection.find_one({"email": email})
89
+ if existing:
90
+ logger.warning(f"Doctor creation failed: Email already exists: {email}")
91
+ raise HTTPException(
92
+ status_code=status.HTTP_409_CONFLICT,
93
+ detail="Email already exists"
94
+ )
95
+
96
+ hashed_pw = hash_password(data.password)
97
+ doctor_doc = {
98
+ "email": email,
99
+ "full_name": data.full_name.strip(),
100
+ "password": hashed_pw,
101
+ "roles": data.roles, # Support multiple roles
102
+ "specialty": data.specialty,
103
+ "license_number": data.license_number,
104
+ "created_at": datetime.utcnow().isoformat(),
105
+ "updated_at": datetime.utcnow().isoformat(),
106
+ "device_token": data.device_token or ""
107
+ }
108
+
109
+ try:
110
+ result = await users_collection.insert_one(doctor_doc)
111
+ logger.info(f"Doctor created successfully: {email}")
112
+ return {
113
+ "status": "success",
114
+ "id": str(result.inserted_id),
115
+ "email": email
116
+ }
117
+ except Exception as e:
118
+ logger.error(f"Failed to create doctor {email}: {str(e)}")
119
+ raise HTTPException(
120
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
121
+ detail=f"Failed to create doctor: {str(e)}"
122
+ )
123
+
124
+ @router.post("/admin/admins", status_code=status.HTTP_201_CREATED)
125
+ async def create_admin(
126
+ data: AdminCreate,
127
+ current_user: dict = Depends(get_current_user)
128
+ ):
129
+ """
130
+ Create admin account - only existing admins can create new admin accounts
131
+ """
132
+ logger.info(f"Admin creation attempt by {current_user.get('email')}")
133
+ if 'admin' not in current_user.get('roles', []):
134
+ logger.warning(f"Unauthorized admin creation attempt by {current_user.get('email')}")
135
+ raise HTTPException(
136
+ status_code=status.HTTP_403_FORBIDDEN,
137
+ detail="Only admins can create admin accounts"
138
+ )
139
+
140
+ email = data.email.lower().strip()
141
+ existing = await users_collection.find_one({"email": email})
142
+ if existing:
143
+ logger.warning(f"Admin creation failed: Email already exists: {email}")
144
+ raise HTTPException(
145
+ status_code=status.HTTP_409_CONFLICT,
146
+ detail="Email already exists"
147
+ )
148
+
149
+ hashed_pw = hash_password(data.password)
150
+ admin_doc = {
151
+ "email": email,
152
+ "full_name": data.full_name.strip(),
153
+ "password": hashed_pw,
154
+ "roles": data.roles, # Support multiple roles
155
+ "created_at": datetime.utcnow().isoformat(),
156
+ "updated_at": datetime.utcnow().isoformat(),
157
+ "device_token": ""
158
+ }
159
+
160
+ try:
161
+ result = await users_collection.insert_one(admin_doc)
162
+ logger.info(f"Admin created successfully: {email}")
163
+ return {
164
+ "status": "success",
165
+ "id": str(result.inserted_id),
166
+ "email": email
167
+ }
168
+ except Exception as e:
169
+ logger.error(f"Failed to create admin {email}: {str(e)}")
170
+ raise HTTPException(
171
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
172
+ detail=f"Failed to create admin: {str(e)}"
173
+ )
174
+
175
+ @router.post("/login", response_model=TokenResponse)
176
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
177
+ logger.info(f"Login attempt for email: {form_data.username}")
178
+ user = await users_collection.find_one({"email": form_data.username.lower()})
179
+ if not user or not verify_password(form_data.password, user["password"]):
180
+ logger.warning(f"Login failed for {form_data.username}: Invalid credentials")
181
+ raise HTTPException(
182
+ status_code=status.HTTP_401_UNAUTHORIZED,
183
+ detail="Invalid credentials",
184
+ headers={"WWW-Authenticate": "Bearer"},
185
+ )
186
+
187
+ # Update device token if provided in form_data (e.g., from frontend)
188
+ if hasattr(form_data, 'device_token') and form_data.device_token:
189
+ await users_collection.update_one(
190
+ {"email": user["email"]},
191
+ {"$set": {"device_token": form_data.device_token}}
192
+ )
193
+ logger.info(f"Device token updated for {form_data.username}")
194
+
195
+ access_token = create_access_token(data={"sub": user["email"]})
196
+ logger.info(f"Successful login for {form_data.username}")
197
+ return {
198
+ "access_token": access_token,
199
+ "token_type": "bearer",
200
+ "roles": user.get("roles", ["patient"]) # Return all roles
201
+ }
202
+
203
+ @router.get("/me")
204
+ async def get_me(request: Request, current_user: dict = Depends(get_current_user)):
205
+ logger.info(f"Fetching user profile for {current_user['email']} at {datetime.utcnow().isoformat()}")
206
+ print(f"Headers: {request.headers}")
207
+ try:
208
+ user = await users_collection.find_one({"email": current_user["email"]})
209
+ if not user:
210
+ logger.warning(f"User not found: {current_user['email']}")
211
+ raise HTTPException(
212
+ status_code=status.HTTP_404_NOT_FOUND,
213
+ detail="User not found"
214
+ )
215
+
216
+ # Handle both "role" (singular) and "roles" (array) formats
217
+ user_roles = user.get("roles", [])
218
+ if not user_roles and user.get("role"):
219
+ # If roles array is empty but role field exists, convert to array
220
+ user_roles = [user.get("role")]
221
+
222
+ print(f"🔍 User from DB: {user}")
223
+ print(f"🔍 User roles: {user_roles}")
224
+
225
+ response = {
226
+ "id": str(user["_id"]),
227
+ "email": user["email"],
228
+ "full_name": user.get("full_name", ""),
229
+ "roles": user_roles, # Return all roles
230
+ "specialty": user.get("specialty"),
231
+ "created_at": user.get("created_at"),
232
+ "updated_at": user.get("updated_at"),
233
+ "device_token": user.get("device_token", "") # Include device token in response
234
+ }
235
+ logger.info(f"User profile retrieved for {current_user['email']} at {datetime.utcnow().isoformat()}")
236
+ return response
237
+ except Exception as e:
238
+ logger.error(f"Database error for user {current_user['email']}: {str(e)} at {datetime.utcnow().isoformat()}")
239
+ raise HTTPException(
240
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
241
+ detail=f"Database error: {str(e)}"
242
+ )
243
+
244
+ @router.get("/users")
245
+ async def list_users(
246
+ role: Optional[str] = None,
247
+ search: Optional[str] = Query(None, description="Search by name or email"),
248
+ current_user: dict = Depends(get_current_user)
249
+ ):
250
+ """
251
+ List users - admins can see all users, doctors can see patients, patients can only see themselves
252
+ """
253
+ logger.info(f"User list request by {current_user.get('email')} with role filter: {role}")
254
+
255
+ # Build query based on user role and requested filter
256
+ query = {}
257
+ if role:
258
+ # support both role singlular and roles array in historical docs
259
+ query["roles"] = {"$in": [role]}
260
+ if search:
261
+ query["$or"] = [
262
+ {"full_name": {"$regex": search, "$options": "i"}},
263
+ {"email": {"$regex": search, "$options": "i"}},
264
+ ]
265
+
266
+ # Role-based access control
267
+ if 'admin' in current_user.get('roles', []):
268
+ # Admins can see all users
269
+ pass
270
+ elif 'doctor' in current_user.get('roles', []):
271
+ # Doctors can only see patients
272
+ query["roles"] = {"$in": ["patient"]}
273
+ elif 'patient' in current_user.get('roles', []):
274
+ # Patients can only see themselves
275
+ query["email"] = current_user.get('email')
276
+
277
+ try:
278
+ users = await users_collection.find(query).limit(500).to_list(length=500)
279
+ # Remove sensitive information
280
+ for user in users:
281
+ user["id"] = str(user["_id"])
282
+ del user["_id"]
283
+ del user["password"]
284
+ user.pop("device_token", None) # Safely remove device_token if it exists
285
+
286
+ logger.info(f"Retrieved {len(users)} users for {current_user.get('email')}")
287
+ return users
288
+ except Exception as e:
289
+ logger.error(f"Error retrieving users: {str(e)}")
290
+ raise HTTPException(
291
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
292
+ detail=f"Failed to retrieve users: {str(e)}"
293
+ )
294
+
295
+ @router.put("/admin/users/{user_id}")
296
+ async def admin_update_user(
297
+ user_id: str,
298
+ data: AdminUserUpdate,
299
+ current_user: dict = Depends(get_current_user)
300
+ ):
301
+ if 'admin' not in current_user.get('roles', []):
302
+ raise HTTPException(status_code=403, detail="Admins only")
303
+ try:
304
+ update_data = {k: v for k, v in data.dict().items() if v is not None}
305
+ update_data["updated_at"] = datetime.utcnow().isoformat()
306
+ result = await users_collection.update_one({"_id": __import__('bson').ObjectId(user_id)}, {"$set": update_data})
307
+ if result.matched_count == 0:
308
+ raise HTTPException(status_code=404, detail="User not found")
309
+ return {"status": "success"}
310
+ except Exception as e:
311
+ raise HTTPException(status_code=500, detail=f"Failed to update user: {str(e)}")
312
+
313
+ @router.delete("/admin/users/{user_id}")
314
+ async def admin_delete_user(
315
+ user_id: str,
316
+ current_user: dict = Depends(get_current_user)
317
+ ):
318
+ if 'admin' not in current_user.get('roles', []):
319
+ raise HTTPException(status_code=403, detail="Admins only")
320
+ try:
321
+ result = await users_collection.delete_one({"_id": __import__('bson').ObjectId(user_id)})
322
+ if result.deleted_count == 0:
323
+ raise HTTPException(status_code=404, detail="User not found")
324
+ return {"status": "success"}
325
+ except Exception as e:
326
+ raise HTTPException(status_code=500, detail=f"Failed to delete user: {str(e)}")
327
+
328
+ @router.post("/admin/users/{user_id}/reset-password")
329
+ async def admin_reset_password(
330
+ user_id: str,
331
+ data: AdminPasswordReset,
332
+ current_user: dict = Depends(get_current_user)
333
+ ):
334
+ if 'admin' not in current_user.get('roles', []):
335
+ raise HTTPException(status_code=403, detail="Admins only")
336
+ if len(data.new_password) < 6:
337
+ raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
338
+ try:
339
+ hashed = hash_password(data.new_password)
340
+ result = await users_collection.update_one(
341
+ {"_id": __import__('bson').ObjectId(user_id)},
342
+ {"$set": {"password": hashed, "updated_at": datetime.utcnow().isoformat()}}
343
+ )
344
+ if result.matched_count == 0:
345
+ raise HTTPException(status_code=404, detail="User not found")
346
+ return {"status": "success"}
347
+ except Exception as e:
348
+ raise HTTPException(status_code=500, detail=f"Failed to reset password: {str(e)}")
349
+
350
+ @router.put("/profile", status_code=status.HTTP_200_OK)
351
+ async def update_profile(
352
+ data: ProfileUpdate,
353
+ current_user: dict = Depends(get_current_user)
354
+ ):
355
+ """
356
+ Update user profile - users can update their own profile
357
+ """
358
+ logger.info(f"Profile update attempt by {current_user.get('email')}")
359
+
360
+ # Build update data (only include fields that are provided)
361
+ update_data = {}
362
+ if data.full_name is not None:
363
+ update_data["full_name"] = data.full_name.strip()
364
+ if data.phone is not None:
365
+ update_data["phone"] = data.phone.strip()
366
+ if data.address is not None:
367
+ update_data["address"] = data.address.strip()
368
+ if data.date_of_birth is not None:
369
+ update_data["date_of_birth"] = data.date_of_birth
370
+ if data.gender is not None:
371
+ update_data["gender"] = data.gender.strip()
372
+ if data.specialty is not None:
373
+ update_data["specialty"] = data.specialty.strip()
374
+ if data.license_number is not None:
375
+ update_data["license_number"] = data.license_number.strip()
376
+
377
+ # Add updated timestamp
378
+ update_data["updated_at"] = datetime.utcnow().isoformat()
379
+
380
+ try:
381
+ result = await users_collection.update_one(
382
+ {"email": current_user.get('email')},
383
+ {"$set": update_data}
384
+ )
385
+
386
+ if result.modified_count == 0:
387
+ raise HTTPException(
388
+ status_code=status.HTTP_404_NOT_FOUND,
389
+ detail="User not found"
390
+ )
391
+
392
+ logger.info(f"Profile updated successfully for {current_user.get('email')}")
393
+ return {
394
+ "status": "success",
395
+ "message": "Profile updated successfully"
396
+ }
397
+ except Exception as e:
398
+ logger.error(f"Failed to update profile for {current_user.get('email')}: {str(e)}")
399
+ raise HTTPException(
400
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
401
+ detail=f"Failed to update profile: {str(e)}"
402
+ )
403
+
404
+ @router.post("/change-password", status_code=status.HTTP_200_OK)
405
+ async def change_password(
406
+ data: PasswordChange,
407
+ current_user: dict = Depends(get_current_user)
408
+ ):
409
+ """
410
+ Change user password - users can change their own password
411
+ """
412
+ logger.info(f"Password change attempt by {current_user.get('email')}")
413
+
414
+ # Verify current password
415
+ if not verify_password(data.current_password, current_user.get('password')):
416
+ logger.warning(f"Password change failed: incorrect current password for {current_user.get('email')}")
417
+ raise HTTPException(
418
+ status_code=status.HTTP_400_BAD_REQUEST,
419
+ detail="Current password is incorrect"
420
+ )
421
+
422
+ # Validate new password
423
+ if len(data.new_password) < 6:
424
+ raise HTTPException(
425
+ status_code=status.HTTP_400_BAD_REQUEST,
426
+ detail="New password must be at least 6 characters long"
427
+ )
428
+
429
+ # Hash new password
430
+ hashed_new_password = hash_password(data.new_password)
431
+
432
+ try:
433
+ result = await users_collection.update_one(
434
+ {"email": current_user.get('email')},
435
+ {
436
+ "$set": {
437
+ "password": hashed_new_password,
438
+ "updated_at": datetime.utcnow().isoformat()
439
+ }
440
+ }
441
+ )
442
+
443
+ if result.modified_count == 0:
444
+ raise HTTPException(
445
+ status_code=status.HTTP_404_NOT_FOUND,
446
+ detail="User not found"
447
+ )
448
+
449
+ logger.info(f"Password changed successfully for {current_user.get('email')}")
450
+ return {
451
+ "status": "success",
452
+ "message": "Password changed successfully"
453
+ }
454
+ except Exception as e:
455
+ logger.error(f"Failed to change password for {current_user.get('email')}: {str(e)}")
456
+ raise HTTPException(
457
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
458
+ detail=f"Failed to change password: {str(e)}"
459
+ )
460
+
461
+ # Export the router as 'auth' for api.__init__.py
462
+ auth = router
api/routes/fhir_integration.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from typing import List, Optional
3
+ import httpx
4
+ import json
5
+ from datetime import datetime
6
+ from ..auth.auth import get_current_user
7
+ from models.schemas import User
8
+
9
+ router = APIRouter()
10
+
11
+ # HAPI FHIR Test Server URL
12
+ HAPI_FHIR_BASE_URL = "https://hapi.fhir.org/baseR4"
13
+
14
+ class FHIRIntegration:
15
+ def __init__(self):
16
+ self.base_url = HAPI_FHIR_BASE_URL
17
+ self.client = httpx.AsyncClient(timeout=30.0)
18
+
19
+ async def search_patients(self, limit: int = 10, offset: int = 0) -> dict:
20
+ """Search for patients in HAPI FHIR Test Server"""
21
+ try:
22
+ url = f"{self.base_url}/Patient"
23
+ params = {
24
+ "_count": limit,
25
+ "_getpagesoffset": offset
26
+ }
27
+
28
+ response = await self.client.get(url, params=params)
29
+ response.raise_for_status()
30
+
31
+ return response.json()
32
+ except Exception as e:
33
+ raise HTTPException(status_code=500, detail=f"FHIR server error: {str(e)}")
34
+
35
+ async def get_patient_by_id(self, patient_id: str) -> dict:
36
+ """Get a specific patient by ID from HAPI FHIR Test Server"""
37
+ try:
38
+ url = f"{self.base_url}/Patient/{patient_id}"
39
+ response = await self.client.get(url)
40
+ response.raise_for_status()
41
+
42
+ return response.json()
43
+ except Exception as e:
44
+ raise HTTPException(status_code=404, detail=f"Patient not found: {str(e)}")
45
+
46
+ async def get_patient_observations(self, patient_id: str) -> dict:
47
+ """Get observations (vital signs, lab results) for a patient"""
48
+ try:
49
+ url = f"{self.base_url}/Observation"
50
+ params = {
51
+ "patient": patient_id,
52
+ "_count": 100
53
+ }
54
+
55
+ response = await self.client.get(url, params=params)
56
+ response.raise_for_status()
57
+
58
+ return response.json()
59
+ except Exception as e:
60
+ raise HTTPException(status_code=500, detail=f"Error fetching observations: {str(e)}")
61
+
62
+ async def get_patient_medications(self, patient_id: str) -> dict:
63
+ """Get medications for a patient"""
64
+ try:
65
+ url = f"{self.base_url}/MedicationRequest"
66
+ params = {
67
+ "patient": patient_id,
68
+ "_count": 100
69
+ }
70
+
71
+ response = await self.client.get(url, params=params)
72
+ response.raise_for_status()
73
+
74
+ return response.json()
75
+ except Exception as e:
76
+ raise HTTPException(status_code=500, detail=f"Error fetching medications: {str(e)}")
77
+
78
+ async def get_patient_conditions(self, patient_id: str) -> dict:
79
+ """Get conditions (diagnoses) for a patient"""
80
+ try:
81
+ url = f"{self.base_url}/Condition"
82
+ params = {
83
+ "patient": patient_id,
84
+ "_count": 100
85
+ }
86
+
87
+ response = await self.client.get(url, params=params)
88
+ response.raise_for_status()
89
+
90
+ return response.json()
91
+ except Exception as e:
92
+ raise HTTPException(status_code=500, detail=f"Error fetching conditions: {str(e)}")
93
+
94
+ async def get_patient_encounters(self, patient_id: str) -> dict:
95
+ """Get encounters (visits) for a patient"""
96
+ try:
97
+ url = f"{self.base_url}/Encounter"
98
+ params = {
99
+ "patient": patient_id,
100
+ "_count": 100
101
+ }
102
+
103
+ response = await self.client.get(url, params=params)
104
+ response.raise_for_status()
105
+
106
+ return response.json()
107
+ except Exception as e:
108
+ raise HTTPException(status_code=500, detail=f"Error fetching encounters: {str(e)}")
109
+
110
+ # Initialize FHIR integration
111
+ fhir_integration = FHIRIntegration()
112
+
113
+ @router.get("/fhir/patients")
114
+ async def get_fhir_patients(
115
+ limit: int = 10,
116
+ offset: int = 0,
117
+ current_user: User = Depends(get_current_user)
118
+ ):
119
+ """Get patients from HAPI FHIR Test Server"""
120
+ try:
121
+ patients_data = await fhir_integration.search_patients(limit, offset)
122
+
123
+ # Transform FHIR patients to our format
124
+ transformed_patients = []
125
+ for patient in patients_data.get("entry", []):
126
+ fhir_patient = patient.get("resource", {})
127
+
128
+ # Extract patient information
129
+ patient_info = {
130
+ "fhir_id": fhir_patient.get("id"),
131
+ "full_name": "",
132
+ "gender": fhir_patient.get("gender", "unknown"),
133
+ "date_of_birth": "",
134
+ "address": "",
135
+ "phone": "",
136
+ "email": ""
137
+ }
138
+
139
+ # Extract name
140
+ if fhir_patient.get("name"):
141
+ name_parts = []
142
+ for name in fhir_patient["name"]:
143
+ if name.get("given"):
144
+ name_parts.extend(name["given"])
145
+ if name.get("family"):
146
+ name_parts.append(name["family"])
147
+ patient_info["full_name"] = " ".join(name_parts)
148
+
149
+ # Extract birth date
150
+ if fhir_patient.get("birthDate"):
151
+ patient_info["date_of_birth"] = fhir_patient["birthDate"]
152
+
153
+ # Extract address
154
+ if fhir_patient.get("address"):
155
+ address_parts = []
156
+ for address in fhir_patient["address"]:
157
+ if address.get("line"):
158
+ address_parts.extend(address["line"])
159
+ if address.get("city"):
160
+ address_parts.append(address["city"])
161
+ if address.get("state"):
162
+ address_parts.append(address["state"])
163
+ if address.get("postalCode"):
164
+ address_parts.append(address["postalCode"])
165
+ patient_info["address"] = ", ".join(address_parts)
166
+
167
+ # Extract contact information
168
+ if fhir_patient.get("telecom"):
169
+ for telecom in fhir_patient["telecom"]:
170
+ if telecom.get("system") == "phone":
171
+ patient_info["phone"] = telecom.get("value", "")
172
+ elif telecom.get("system") == "email":
173
+ patient_info["email"] = telecom.get("value", "")
174
+
175
+ transformed_patients.append(patient_info)
176
+
177
+ return {
178
+ "patients": transformed_patients,
179
+ "total": patients_data.get("total", len(transformed_patients)),
180
+ "count": len(transformed_patients)
181
+ }
182
+
183
+ except Exception as e:
184
+ raise HTTPException(status_code=500, detail=f"Error fetching FHIR patients: {str(e)}")
185
+
186
+ @router.get("/fhir/patients/{patient_id}")
187
+ async def get_fhir_patient_details(
188
+ patient_id: str,
189
+ current_user: User = Depends(get_current_user)
190
+ ):
191
+ """Get detailed patient information from HAPI FHIR Test Server"""
192
+ try:
193
+ # Get basic patient info
194
+ patient_data = await fhir_integration.get_patient_by_id(patient_id)
195
+
196
+ # Get additional patient data
197
+ observations = await fhir_integration.get_patient_observations(patient_id)
198
+ medications = await fhir_integration.get_patient_medications(patient_id)
199
+ conditions = await fhir_integration.get_patient_conditions(patient_id)
200
+ encounters = await fhir_integration.get_patient_encounters(patient_id)
201
+
202
+ # Transform and combine all data
203
+ patient_info = {
204
+ "fhir_id": patient_data.get("id"),
205
+ "full_name": "",
206
+ "gender": patient_data.get("gender", "unknown"),
207
+ "date_of_birth": patient_data.get("birthDate", ""),
208
+ "address": "",
209
+ "phone": "",
210
+ "email": "",
211
+ "observations": [],
212
+ "medications": [],
213
+ "conditions": [],
214
+ "encounters": []
215
+ }
216
+
217
+ # Extract name
218
+ if patient_data.get("name"):
219
+ name_parts = []
220
+ for name in patient_data["name"]:
221
+ if name.get("given"):
222
+ name_parts.extend(name["given"])
223
+ if name.get("family"):
224
+ name_parts.append(name["family"])
225
+ patient_info["full_name"] = " ".join(name_parts)
226
+
227
+ # Extract address
228
+ if patient_data.get("address"):
229
+ address_parts = []
230
+ for address in patient_data["address"]:
231
+ if address.get("line"):
232
+ address_parts.extend(address["line"])
233
+ if address.get("city"):
234
+ address_parts.append(address["city"])
235
+ if address.get("state"):
236
+ address_parts.append(address["state"])
237
+ if address.get("postalCode"):
238
+ address_parts.append(address["postalCode"])
239
+ patient_info["address"] = ", ".join(address_parts)
240
+
241
+ # Extract contact information
242
+ if patient_data.get("telecom"):
243
+ for telecom in patient_data["telecom"]:
244
+ if telecom.get("system") == "phone":
245
+ patient_info["phone"] = telecom.get("value", "")
246
+ elif telecom.get("system") == "email":
247
+ patient_info["email"] = telecom.get("value", "")
248
+
249
+ # Transform observations
250
+ for obs in observations.get("entry", []):
251
+ resource = obs.get("resource", {})
252
+ if resource.get("code", {}).get("text"):
253
+ patient_info["observations"].append({
254
+ "type": resource["code"]["text"],
255
+ "value": resource.get("valueQuantity", {}).get("value"),
256
+ "unit": resource.get("valueQuantity", {}).get("unit"),
257
+ "date": resource.get("effectiveDateTime", "")
258
+ })
259
+
260
+ # Transform medications
261
+ for med in medications.get("entry", []):
262
+ resource = med.get("resource", {})
263
+ if resource.get("medicationCodeableConcept", {}).get("text"):
264
+ patient_info["medications"].append({
265
+ "name": resource["medicationCodeableConcept"]["text"],
266
+ "status": resource.get("status", ""),
267
+ "prescribed_date": resource.get("authoredOn", ""),
268
+ "dosage": resource.get("dosageInstruction", [{}])[0].get("text", "")
269
+ })
270
+
271
+ # Transform conditions
272
+ for condition in conditions.get("entry", []):
273
+ resource = condition.get("resource", {})
274
+ if resource.get("code", {}).get("text"):
275
+ patient_info["conditions"].append({
276
+ "name": resource["code"]["text"],
277
+ "status": resource.get("clinicalStatus", {}).get("text", ""),
278
+ "onset_date": resource.get("onsetDateTime", ""),
279
+ "severity": resource.get("severity", {}).get("text", "")
280
+ })
281
+
282
+ # Transform encounters
283
+ for encounter in encounters.get("entry", []):
284
+ resource = encounter.get("resource", {})
285
+ if resource.get("type"):
286
+ patient_info["encounters"].append({
287
+ "type": resource["type"][0].get("text", ""),
288
+ "status": resource.get("status", ""),
289
+ "start_date": resource.get("period", {}).get("start", ""),
290
+ "end_date": resource.get("period", {}).get("end", ""),
291
+ "service_provider": resource.get("serviceProvider", {}).get("display", "")
292
+ })
293
+
294
+ return patient_info
295
+
296
+ except Exception as e:
297
+ raise HTTPException(status_code=500, detail=f"Error fetching FHIR patient details: {str(e)}")
298
+
299
+ @router.post("/fhir/import-patient/{patient_id}")
300
+ async def import_fhir_patient(
301
+ patient_id: str,
302
+ current_user: User = Depends(get_current_user)
303
+ ):
304
+ """Import a patient from HAPI FHIR Test Server to our database"""
305
+ try:
306
+ from db.mongo import patients_collection
307
+ from bson import ObjectId
308
+
309
+ # Get patient data from FHIR server
310
+ patient_data = await fhir_integration.get_patient_by_id(patient_id)
311
+
312
+ # Transform FHIR data to our format
313
+ transformed_patient = {
314
+ "fhir_id": patient_data.get("id"),
315
+ "full_name": "",
316
+ "gender": patient_data.get("gender", "unknown"),
317
+ "date_of_birth": patient_data.get("birthDate", ""),
318
+ "address": "",
319
+ "phone": "",
320
+ "email": "",
321
+ "source": "fhir_import",
322
+ "status": "active",
323
+ "assigned_doctor_id": str(current_user.id),
324
+ "created_at": datetime.now(),
325
+ "updated_at": datetime.now()
326
+ }
327
+
328
+ # Extract name
329
+ if patient_data.get("name"):
330
+ name_parts = []
331
+ for name in patient_data["name"]:
332
+ if name.get("given"):
333
+ name_parts.extend(name["given"])
334
+ if name.get("family"):
335
+ name_parts.append(name["family"])
336
+ transformed_patient["full_name"] = " ".join(name_parts)
337
+
338
+ # Extract address
339
+ if patient_data.get("address"):
340
+ address_parts = []
341
+ for address in patient_data["address"]:
342
+ if address.get("line"):
343
+ address_parts.extend(address["line"])
344
+ if address.get("city"):
345
+ address_parts.append(address["city"])
346
+ if address.get("state"):
347
+ address_parts.append(address["state"])
348
+ if address.get("postalCode"):
349
+ address_parts.append(address["postalCode"])
350
+ transformed_patient["address"] = ", ".join(address_parts)
351
+
352
+ # Extract contact information
353
+ if patient_data.get("telecom"):
354
+ for telecom in patient_data["telecom"]:
355
+ if telecom.get("system") == "phone":
356
+ transformed_patient["phone"] = telecom.get("value", "")
357
+ elif telecom.get("system") == "email":
358
+ transformed_patient["email"] = telecom.get("value", "")
359
+
360
+ # Check if patient already exists
361
+ existing_patient = await patients_collection.find_one({"fhir_id": patient_data.get("id")})
362
+ if existing_patient:
363
+ raise HTTPException(status_code=400, detail="Patient already exists in database")
364
+
365
+ # Insert patient into database
366
+ result = await patients_collection.insert_one(transformed_patient)
367
+
368
+ return {
369
+ "message": "Patient imported successfully",
370
+ "patient_id": str(result.inserted_id),
371
+ "fhir_id": patient_data.get("id")
372
+ }
373
+
374
+ except Exception as e:
375
+ raise HTTPException(status_code=500, detail=f"Error importing FHIR patient: {str(e)}")
api/routes/messaging.py ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, WebSocket, WebSocketDisconnect, UploadFile, File
2
+ from typing import List, Optional
3
+ from datetime import datetime, timedelta
4
+ from bson import ObjectId
5
+ from motor.motor_asyncio import AsyncIOMotorClient
6
+ import json
7
+ import asyncio
8
+ from collections import defaultdict
9
+ import os
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ from core.security import get_current_user
14
+ from db.mongo import db
15
+ from models.schemas import (
16
+ MessageCreate, MessageUpdate, MessageResponse, MessageListResponse,
17
+ ConversationResponse, ConversationListResponse, MessageType, MessageStatus,
18
+ NotificationCreate, NotificationResponse, NotificationListResponse,
19
+ NotificationType, NotificationPriority
20
+ )
21
+
22
+ router = APIRouter(prefix="/messaging", tags=["messaging"])
23
+
24
+ # WebSocket connection manager
25
+ class ConnectionManager:
26
+ def __init__(self):
27
+ self.active_connections: dict = defaultdict(list) # user_id -> list of connections
28
+
29
+ async def connect(self, websocket: WebSocket, user_id: str):
30
+ await websocket.accept()
31
+ self.active_connections[user_id].append(websocket)
32
+
33
+ def disconnect(self, websocket: WebSocket, user_id: str):
34
+ if user_id in self.active_connections:
35
+ self.active_connections[user_id] = [
36
+ conn for conn in self.active_connections[user_id] if conn != websocket
37
+ ]
38
+
39
+ async def send_personal_message(self, message: dict, user_id: str):
40
+ if user_id in self.active_connections:
41
+ for connection in self.active_connections[user_id]:
42
+ try:
43
+ await connection.send_text(json.dumps(message))
44
+ except:
45
+ # Remove dead connections
46
+ self.active_connections[user_id].remove(connection)
47
+
48
+ manager = ConnectionManager()
49
+
50
+ # --- HELPER FUNCTIONS ---
51
+ def is_valid_object_id(id_str: str) -> bool:
52
+ try:
53
+ ObjectId(id_str)
54
+ return True
55
+ except:
56
+ return False
57
+
58
+ def get_conversation_id(user1_id, user2_id) -> str:
59
+ """Generate a consistent conversation ID for two users"""
60
+ # Convert both IDs to strings for consistent comparison
61
+ user1_str = str(user1_id)
62
+ user2_str = str(user2_id)
63
+ # Sort IDs to ensure consistent conversation ID regardless of sender/recipient
64
+ sorted_ids = sorted([user1_str, user2_str])
65
+ return f"{sorted_ids[0]}_{sorted_ids[1]}"
66
+
67
+ async def create_notification(
68
+ db_client: AsyncIOMotorClient,
69
+ recipient_id: str,
70
+ title: str,
71
+ message: str,
72
+ notification_type: NotificationType,
73
+ priority: NotificationPriority = NotificationPriority.MEDIUM,
74
+ data: Optional[dict] = None
75
+ ):
76
+ """Create a notification for a user"""
77
+ notification_doc = {
78
+ "recipient_id": ObjectId(recipient_id),
79
+ "title": title,
80
+ "message": message,
81
+ "notification_type": notification_type,
82
+ "priority": priority,
83
+ "data": data or {},
84
+ "is_read": False,
85
+ "created_at": datetime.now()
86
+ }
87
+
88
+ result = await db_client.notifications.insert_one(notification_doc)
89
+ notification_doc["_id"] = result.inserted_id
90
+
91
+ # Convert ObjectId to string for WebSocket transmission
92
+ notification_for_ws = {
93
+ "id": str(notification_doc["_id"]),
94
+ "recipient_id": str(notification_doc["recipient_id"]),
95
+ "title": notification_doc["title"],
96
+ "message": notification_doc["message"],
97
+ "notification_type": notification_doc["notification_type"],
98
+ "priority": notification_doc["priority"],
99
+ "data": notification_doc["data"],
100
+ "is_read": notification_doc["is_read"],
101
+ "created_at": notification_doc["created_at"]
102
+ }
103
+
104
+ # Send real-time notification via WebSocket
105
+ await manager.send_personal_message({
106
+ "type": "new_notification",
107
+ "data": notification_for_ws
108
+ }, recipient_id)
109
+
110
+ return notification_doc
111
+
112
+ # --- WEBSOCKET ENDPOINT ---
113
+ @router.websocket("/ws/{user_id}")
114
+ async def websocket_endpoint(websocket: WebSocket, user_id: str):
115
+ await manager.connect(websocket, user_id)
116
+ print(f"🔌 WebSocket connected for user: {user_id}")
117
+
118
+ try:
119
+ while True:
120
+ # Wait for messages from client (keep connection alive)
121
+ data = await websocket.receive_text()
122
+ try:
123
+ message_data = json.loads(data)
124
+ if message_data.get("type") == "ping":
125
+ # Send pong to keep connection alive
126
+ await websocket.send_text(json.dumps({"type": "pong"}))
127
+ except json.JSONDecodeError:
128
+ pass # Ignore invalid JSON
129
+ except WebSocketDisconnect:
130
+ print(f"🔌 WebSocket disconnected for user: {user_id}")
131
+ manager.disconnect(websocket, user_id)
132
+ except Exception as e:
133
+ print(f"❌ WebSocket error for user {user_id}: {e}")
134
+ manager.disconnect(websocket, user_id)
135
+
136
+ # --- CONVERSATION ENDPOINTS ---
137
+ @router.get("/conversations", response_model=ConversationListResponse)
138
+ async def get_conversations(
139
+ page: int = Query(1, ge=1),
140
+ limit: int = Query(20, ge=1, le=100),
141
+ current_user: dict = Depends(get_current_user),
142
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
143
+ ):
144
+ """Get user's conversations"""
145
+ skip = (page - 1) * limit
146
+ user_id = current_user["_id"]
147
+
148
+ # Get all messages where user is sender or recipient
149
+ pipeline = [
150
+ {
151
+ "$match": {
152
+ "$or": [
153
+ {"sender_id": ObjectId(user_id)},
154
+ {"recipient_id": ObjectId(user_id)}
155
+ ]
156
+ }
157
+ },
158
+ {
159
+ "$sort": {"created_at": -1}
160
+ },
161
+ {
162
+ "$group": {
163
+ "_id": {
164
+ "$cond": [
165
+ {"$eq": ["$sender_id", ObjectId(user_id)]},
166
+ "$recipient_id",
167
+ "$sender_id"
168
+ ]
169
+ },
170
+ "last_message": {"$first": "$$ROOT"},
171
+ "unread_count": {
172
+ "$sum": {
173
+ "$cond": [
174
+ {
175
+ "$and": [
176
+ {"$eq": ["$recipient_id", ObjectId(user_id)]},
177
+ {"$ne": ["$status", "read"]}
178
+ ]
179
+ },
180
+ 1,
181
+ 0
182
+ ]
183
+ }
184
+ }
185
+ }
186
+ },
187
+ {
188
+ "$sort": {"last_message.created_at": -1}
189
+ },
190
+ {
191
+ "$skip": skip
192
+ },
193
+ {
194
+ "$limit": limit
195
+ }
196
+ ]
197
+
198
+ conversations_data = await db_client.messages.aggregate(pipeline).to_list(length=limit)
199
+
200
+ # Get user details for each conversation
201
+ conversations = []
202
+ for conv_data in conversations_data:
203
+ other_user_id = str(conv_data["_id"])
204
+ other_user = await db_client.users.find_one({"_id": conv_data["_id"]})
205
+
206
+ if other_user:
207
+ # Convert user_id to string for consistent comparison
208
+ user_id_str = str(user_id)
209
+ conversation_id = get_conversation_id(user_id_str, other_user_id)
210
+
211
+ # Build last message response
212
+ last_message = None
213
+ if conv_data["last_message"]:
214
+ last_message = MessageResponse(
215
+ id=str(conv_data["last_message"]["_id"]),
216
+ sender_id=str(conv_data["last_message"]["sender_id"]),
217
+ recipient_id=str(conv_data["last_message"]["recipient_id"]),
218
+ sender_name=current_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else other_user["full_name"],
219
+ recipient_name=other_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else current_user["full_name"],
220
+ content=conv_data["last_message"]["content"],
221
+ message_type=conv_data["last_message"]["message_type"],
222
+ attachment_url=conv_data["last_message"].get("attachment_url"),
223
+ reply_to_message_id=str(conv_data["last_message"]["reply_to_message_id"]) if conv_data["last_message"].get("reply_to_message_id") else None,
224
+ status=conv_data["last_message"]["status"],
225
+ is_archived=conv_data["last_message"].get("is_archived", False),
226
+ created_at=conv_data["last_message"]["created_at"],
227
+ updated_at=conv_data["last_message"]["updated_at"],
228
+ read_at=conv_data["last_message"].get("read_at")
229
+ )
230
+
231
+ conversations.append(ConversationResponse(
232
+ id=conversation_id,
233
+ participant_ids=[user_id_str, other_user_id],
234
+ participant_names=[current_user["full_name"], other_user["full_name"]],
235
+ last_message=last_message,
236
+ unread_count=conv_data["unread_count"],
237
+ created_at=conv_data["last_message"]["created_at"] if conv_data["last_message"] else datetime.now(),
238
+ updated_at=conv_data["last_message"]["updated_at"] if conv_data["last_message"] else datetime.now()
239
+ ))
240
+
241
+ # Get total count
242
+ total_pipeline = [
243
+ {
244
+ "$match": {
245
+ "$or": [
246
+ {"sender_id": ObjectId(user_id)},
247
+ {"recipient_id": ObjectId(user_id)}
248
+ ]
249
+ }
250
+ },
251
+ {
252
+ "$group": {
253
+ "_id": {
254
+ "$cond": [
255
+ {"$eq": ["$sender_id", ObjectId(user_id)]},
256
+ "$recipient_id",
257
+ "$sender_id"
258
+ ]
259
+ }
260
+ }
261
+ },
262
+ {
263
+ "$count": "total"
264
+ }
265
+ ]
266
+
267
+ total_result = await db_client.messages.aggregate(total_pipeline).to_list(length=1)
268
+ total = total_result[0]["total"] if total_result else 0
269
+
270
+ return ConversationListResponse(
271
+ conversations=conversations,
272
+ total=total,
273
+ page=page,
274
+ limit=limit
275
+ )
276
+
277
+ # --- MESSAGE ENDPOINTS ---
278
+ @router.post("/messages", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
279
+ async def send_message(
280
+ message_data: MessageCreate,
281
+ current_user: dict = Depends(get_current_user),
282
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
283
+ ):
284
+ """Send a message to another user"""
285
+ if not is_valid_object_id(message_data.recipient_id):
286
+ raise HTTPException(
287
+ status_code=status.HTTP_400_BAD_REQUEST,
288
+ detail="Invalid recipient ID"
289
+ )
290
+
291
+ # Check if recipient exists
292
+ recipient = await db_client.users.find_one({"_id": ObjectId(message_data.recipient_id)})
293
+ if not recipient:
294
+ raise HTTPException(
295
+ status_code=status.HTTP_404_NOT_FOUND,
296
+ detail="Recipient not found"
297
+ )
298
+
299
+ # Check if user can message this recipient
300
+ # Patients can only message their doctors, doctors can message their patients
301
+ current_user_roles = current_user.get('roles', [])
302
+ if isinstance(current_user.get('role'), str):
303
+ current_user_roles.append(current_user.get('role'))
304
+
305
+ recipient_roles = recipient.get('roles', [])
306
+ if isinstance(recipient.get('role'), str):
307
+ recipient_roles.append(recipient.get('role'))
308
+
309
+ if 'patient' in current_user_roles:
310
+ # Patients can only message doctors
311
+ if 'doctor' not in recipient_roles:
312
+ raise HTTPException(
313
+ status_code=status.HTTP_403_FORBIDDEN,
314
+ detail="Patients can only message doctors"
315
+ )
316
+ elif 'doctor' in current_user_roles:
317
+ # Doctors can only message their patients
318
+ if 'patient' not in recipient_roles:
319
+ raise HTTPException(
320
+ status_code=status.HTTP_403_FORBIDDEN,
321
+ detail="Doctors can only message patients"
322
+ )
323
+
324
+ # Check reply message if provided
325
+ if message_data.reply_to_message_id:
326
+ if not is_valid_object_id(message_data.reply_to_message_id):
327
+ raise HTTPException(
328
+ status_code=status.HTTP_400_BAD_REQUEST,
329
+ detail="Invalid reply message ID"
330
+ )
331
+
332
+ reply_message = await db_client.messages.find_one({"_id": ObjectId(message_data.reply_to_message_id)})
333
+ if not reply_message:
334
+ raise HTTPException(
335
+ status_code=status.HTTP_404_NOT_FOUND,
336
+ detail="Reply message not found"
337
+ )
338
+
339
+ # Create message
340
+ message_doc = {
341
+ "sender_id": ObjectId(current_user["_id"]),
342
+ "recipient_id": ObjectId(message_data.recipient_id),
343
+ "content": message_data.content,
344
+ "message_type": message_data.message_type,
345
+ "attachment_url": message_data.attachment_url,
346
+ "reply_to_message_id": ObjectId(message_data.reply_to_message_id) if message_data.reply_to_message_id else None,
347
+ "status": MessageStatus.SENT,
348
+ "is_archived": False,
349
+ "created_at": datetime.now(),
350
+ "updated_at": datetime.now()
351
+ }
352
+
353
+ result = await db_client.messages.insert_one(message_doc)
354
+ message_doc["_id"] = result.inserted_id
355
+
356
+ # Send real-time message via WebSocket
357
+ await manager.send_personal_message({
358
+ "type": "new_message",
359
+ "data": {
360
+ "id": str(message_doc["_id"]),
361
+ "sender_id": str(message_doc["sender_id"]),
362
+ "recipient_id": str(message_doc["recipient_id"]),
363
+ "sender_name": current_user["full_name"],
364
+ "recipient_name": recipient["full_name"],
365
+ "content": message_doc["content"],
366
+ "message_type": message_doc["message_type"],
367
+ "attachment_url": message_doc["attachment_url"],
368
+ "reply_to_message_id": str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
369
+ "status": message_doc["status"],
370
+ "is_archived": message_doc["is_archived"],
371
+ "created_at": message_doc["created_at"],
372
+ "updated_at": message_doc["updated_at"]
373
+ }
374
+ }, message_data.recipient_id)
375
+
376
+ # Create notification for recipient
377
+ await create_notification(
378
+ db_client,
379
+ message_data.recipient_id,
380
+ f"New message from {current_user['full_name']}",
381
+ message_data.content[:100] + "..." if len(message_data.content) > 100 else message_data.content,
382
+ NotificationType.MESSAGE,
383
+ NotificationPriority.MEDIUM,
384
+ {"message_id": str(message_doc["_id"]), "sender_id": str(current_user["_id"])}
385
+ )
386
+
387
+ return MessageResponse(
388
+ id=str(message_doc["_id"]),
389
+ sender_id=str(message_doc["sender_id"]),
390
+ recipient_id=str(message_doc["recipient_id"]),
391
+ sender_name=current_user["full_name"],
392
+ recipient_name=recipient["full_name"],
393
+ content=message_doc["content"],
394
+ message_type=message_doc["message_type"],
395
+ attachment_url=message_doc["attachment_url"],
396
+ reply_to_message_id=str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
397
+ status=message_doc["status"],
398
+ is_archived=message_doc["is_archived"],
399
+ created_at=message_doc["created_at"],
400
+ updated_at=message_doc["updated_at"]
401
+ )
402
+
403
+ @router.get("/messages/{conversation_id}", response_model=MessageListResponse)
404
+ async def get_messages(
405
+ conversation_id: str,
406
+ page: int = Query(1, ge=1),
407
+ limit: int = Query(50, ge=1, le=100),
408
+ current_user: dict = Depends(get_current_user),
409
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
410
+ ):
411
+ """Get messages for a specific conversation"""
412
+ skip = (page - 1) * limit
413
+ user_id = current_user["_id"]
414
+
415
+ # Parse conversation ID to get the other participant
416
+ try:
417
+ participant_ids = conversation_id.split("_")
418
+ if len(participant_ids) != 2:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_400_BAD_REQUEST,
421
+ detail="Invalid conversation ID format"
422
+ )
423
+
424
+ # Find the other participant
425
+ other_user_id = None
426
+ user_id_str = str(user_id)
427
+ for pid in participant_ids:
428
+ if pid != user_id_str:
429
+ other_user_id = pid
430
+ break
431
+
432
+ if not other_user_id or not is_valid_object_id(other_user_id):
433
+ raise HTTPException(
434
+ status_code=status.HTTP_400_BAD_REQUEST,
435
+ detail="Invalid conversation ID"
436
+ )
437
+
438
+ # Verify the other user exists
439
+ other_user = await db_client.users.find_one({"_id": ObjectId(other_user_id)})
440
+ if not other_user:
441
+ raise HTTPException(
442
+ status_code=status.HTTP_404_NOT_FOUND,
443
+ detail="Conversation participant not found"
444
+ )
445
+
446
+ except Exception as e:
447
+ raise HTTPException(
448
+ status_code=status.HTTP_400_BAD_REQUEST,
449
+ detail="Invalid conversation ID"
450
+ )
451
+
452
+ # Get messages between the two users
453
+ filter_query = {
454
+ "$or": [
455
+ {
456
+ "sender_id": ObjectId(user_id),
457
+ "recipient_id": ObjectId(other_user_id)
458
+ },
459
+ {
460
+ "sender_id": ObjectId(other_user_id),
461
+ "recipient_id": ObjectId(user_id)
462
+ }
463
+ ]
464
+ }
465
+
466
+ # Get messages
467
+ cursor = db_client.messages.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
468
+ messages = await cursor.to_list(length=limit)
469
+
470
+ # Mark messages as read
471
+ unread_messages = [
472
+ msg["_id"] for msg in messages
473
+ if msg["recipient_id"] == ObjectId(user_id) and msg["status"] != "read"
474
+ ]
475
+
476
+ if unread_messages:
477
+ await db_client.messages.update_many(
478
+ {"_id": {"$in": unread_messages}},
479
+ {"$set": {"status": "read", "read_at": datetime.now()}}
480
+ )
481
+
482
+ # Get total count
483
+ total = await db_client.messages.count_documents(filter_query)
484
+
485
+ # Build message responses
486
+ message_responses = []
487
+ for msg in messages:
488
+ sender = await db_client.users.find_one({"_id": msg["sender_id"]})
489
+ recipient = await db_client.users.find_one({"_id": msg["recipient_id"]})
490
+
491
+ message_responses.append(MessageResponse(
492
+ id=str(msg["_id"]),
493
+ sender_id=str(msg["sender_id"]),
494
+ recipient_id=str(msg["recipient_id"]),
495
+ sender_name=sender["full_name"] if sender else "Unknown User",
496
+ recipient_name=recipient["full_name"] if recipient else "Unknown User",
497
+ content=msg["content"],
498
+ message_type=msg["message_type"],
499
+ attachment_url=msg.get("attachment_url"),
500
+ reply_to_message_id=str(msg["reply_to_message_id"]) if msg.get("reply_to_message_id") else None,
501
+ status=msg["status"],
502
+ is_archived=msg.get("is_archived", False),
503
+ created_at=msg["created_at"],
504
+ updated_at=msg["updated_at"],
505
+ read_at=msg.get("read_at")
506
+ ))
507
+
508
+ return MessageListResponse(
509
+ messages=message_responses,
510
+ total=total,
511
+ page=page,
512
+ limit=limit,
513
+ conversation_id=conversation_id
514
+ )
515
+
516
+ @router.put("/messages/{message_id}", response_model=MessageResponse)
517
+ async def update_message(
518
+ message_id: str,
519
+ message_data: MessageUpdate,
520
+ current_user: dict = Depends(get_current_user),
521
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
522
+ ):
523
+ """Update a message (only sender can update)"""
524
+ if not is_valid_object_id(message_id):
525
+ raise HTTPException(
526
+ status_code=status.HTTP_400_BAD_REQUEST,
527
+ detail="Invalid message ID"
528
+ )
529
+
530
+ message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
531
+ if not message:
532
+ raise HTTPException(
533
+ status_code=status.HTTP_404_NOT_FOUND,
534
+ detail="Message not found"
535
+ )
536
+
537
+ # Only sender can update message
538
+ if message["sender_id"] != ObjectId(current_user["_id"]):
539
+ raise HTTPException(
540
+ status_code=status.HTTP_403_FORBIDDEN,
541
+ detail="You can only update your own messages"
542
+ )
543
+
544
+ # Build update data
545
+ update_data = {"updated_at": datetime.now()}
546
+
547
+ if message_data.content is not None:
548
+ update_data["content"] = message_data.content
549
+ if message_data.is_archived is not None:
550
+ update_data["is_archived"] = message_data.is_archived
551
+
552
+ # Update message
553
+ await db_client.messages.update_one(
554
+ {"_id": ObjectId(message_id)},
555
+ {"$set": update_data}
556
+ )
557
+
558
+ # Get updated message
559
+ updated_message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
560
+
561
+ # Get sender and recipient names
562
+ sender = await db_client.users.find_one({"_id": updated_message["sender_id"]})
563
+ recipient = await db_client.users.find_one({"_id": updated_message["recipient_id"]})
564
+
565
+ return MessageResponse(
566
+ id=str(updated_message["_id"]),
567
+ sender_id=str(updated_message["sender_id"]),
568
+ recipient_id=str(updated_message["recipient_id"]),
569
+ sender_name=sender["full_name"] if sender else "Unknown User",
570
+ recipient_name=recipient["full_name"] if recipient else "Unknown User",
571
+ content=updated_message["content"],
572
+ message_type=updated_message["message_type"],
573
+ attachment_url=updated_message.get("attachment_url"),
574
+ reply_to_message_id=str(updated_message["reply_to_message_id"]) if updated_message.get("reply_to_message_id") else None,
575
+ status=updated_message["status"],
576
+ is_archived=updated_message.get("is_archived", False),
577
+ created_at=updated_message["created_at"],
578
+ updated_at=updated_message["updated_at"],
579
+ read_at=updated_message.get("read_at")
580
+ )
581
+
582
+ @router.delete("/messages/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
583
+ async def delete_message(
584
+ message_id: str,
585
+ current_user: dict = Depends(get_current_user),
586
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
587
+ ):
588
+ """Delete a message (only sender can delete)"""
589
+ if not is_valid_object_id(message_id):
590
+ raise HTTPException(
591
+ status_code=status.HTTP_400_BAD_REQUEST,
592
+ detail="Invalid message ID"
593
+ )
594
+
595
+ message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
596
+ if not message:
597
+ raise HTTPException(
598
+ status_code=status.HTTP_404_NOT_FOUND,
599
+ detail="Message not found"
600
+ )
601
+
602
+ # Only sender can delete message
603
+ if message["sender_id"] != ObjectId(current_user["_id"]):
604
+ raise HTTPException(
605
+ status_code=status.HTTP_403_FORBIDDEN,
606
+ detail="You can only delete your own messages"
607
+ )
608
+
609
+ await db_client.messages.delete_one({"_id": ObjectId(message_id)})
610
+
611
+ # --- NOTIFICATION ENDPOINTS ---
612
+ @router.get("/notifications", response_model=NotificationListResponse)
613
+ async def get_notifications(
614
+ page: int = Query(1, ge=1),
615
+ limit: int = Query(20, ge=1, le=100),
616
+ unread_only: bool = Query(False),
617
+ current_user: dict = Depends(get_current_user),
618
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
619
+ ):
620
+ """Get user's notifications"""
621
+ skip = (page - 1) * limit
622
+ user_id = current_user["_id"]
623
+
624
+ # Build filter
625
+ filter_query = {"recipient_id": ObjectId(user_id)}
626
+ if unread_only:
627
+ filter_query["is_read"] = False
628
+
629
+ # Get notifications
630
+ cursor = db_client.notifications.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
631
+ notifications = await cursor.to_list(length=limit)
632
+
633
+ # Get total count and unread count
634
+ total = await db_client.notifications.count_documents(filter_query)
635
+ unread_count = await db_client.notifications.count_documents({
636
+ "recipient_id": ObjectId(user_id),
637
+ "is_read": False
638
+ })
639
+
640
+ # Build notification responses
641
+ notification_responses = []
642
+ for notif in notifications:
643
+ # Convert any ObjectId fields in data to strings
644
+ data = notif.get("data", {})
645
+ if data:
646
+ # Convert ObjectId fields to strings
647
+ for key, value in data.items():
648
+ if isinstance(value, ObjectId):
649
+ data[key] = str(value)
650
+
651
+ notification_responses.append(NotificationResponse(
652
+ id=str(notif["_id"]),
653
+ recipient_id=str(notif["recipient_id"]),
654
+ recipient_name=current_user["full_name"],
655
+ title=notif["title"],
656
+ message=notif["message"],
657
+ notification_type=notif["notification_type"],
658
+ priority=notif["priority"],
659
+ data=data,
660
+ is_read=notif.get("is_read", False),
661
+ created_at=notif["created_at"],
662
+ read_at=notif.get("read_at")
663
+ ))
664
+
665
+ return NotificationListResponse(
666
+ notifications=notification_responses,
667
+ total=total,
668
+ unread_count=unread_count,
669
+ page=page,
670
+ limit=limit
671
+ )
672
+
673
+ @router.put("/notifications/{notification_id}/read", response_model=NotificationResponse)
674
+ async def mark_notification_read(
675
+ notification_id: str,
676
+ current_user: dict = Depends(get_current_user),
677
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
678
+ ):
679
+ """Mark a notification as read"""
680
+ if not is_valid_object_id(notification_id):
681
+ raise HTTPException(
682
+ status_code=status.HTTP_400_BAD_REQUEST,
683
+ detail="Invalid notification ID"
684
+ )
685
+
686
+ notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
687
+ if not notification:
688
+ raise HTTPException(
689
+ status_code=status.HTTP_404_NOT_FOUND,
690
+ detail="Notification not found"
691
+ )
692
+
693
+ # Only recipient can mark as read
694
+ if notification["recipient_id"] != ObjectId(current_user["_id"]):
695
+ raise HTTPException(
696
+ status_code=status.HTTP_403_FORBIDDEN,
697
+ detail="You can only mark your own notifications as read"
698
+ )
699
+
700
+ # Update notification
701
+ await db_client.notifications.update_one(
702
+ {"_id": ObjectId(notification_id)},
703
+ {"$set": {"is_read": True, "read_at": datetime.now()}}
704
+ )
705
+
706
+ # Get updated notification
707
+ updated_notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
708
+
709
+ # Convert any ObjectId fields in data to strings
710
+ data = updated_notification.get("data", {})
711
+ if data:
712
+ # Convert ObjectId fields to strings
713
+ for key, value in data.items():
714
+ if isinstance(value, ObjectId):
715
+ data[key] = str(value)
716
+
717
+ return NotificationResponse(
718
+ id=str(updated_notification["_id"]),
719
+ recipient_id=str(updated_notification["recipient_id"]),
720
+ recipient_name=current_user["full_name"],
721
+ title=updated_notification["title"],
722
+ message=updated_notification["message"],
723
+ notification_type=updated_notification["notification_type"],
724
+ priority=updated_notification["priority"],
725
+ data=data,
726
+ is_read=updated_notification.get("is_read", False),
727
+ created_at=updated_notification["created_at"],
728
+ read_at=updated_notification.get("read_at")
729
+ )
730
+
731
+ @router.put("/notifications/read-all", status_code=status.HTTP_200_OK)
732
+ async def mark_all_notifications_read(
733
+ current_user: dict = Depends(get_current_user),
734
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
735
+ ):
736
+ """Mark all user's notifications as read"""
737
+ user_id = current_user["_id"]
738
+
739
+ await db_client.notifications.update_many(
740
+ {
741
+ "recipient_id": ObjectId(user_id),
742
+ "is_read": False
743
+ },
744
+ {
745
+ "$set": {
746
+ "is_read": True,
747
+ "read_at": datetime.now()
748
+ }
749
+ }
750
+ )
751
+
752
+ return {"message": "All notifications marked as read"}
753
+
754
+ # --- FILE UPLOAD ENDPOINT ---
755
+ @router.post("/upload", status_code=status.HTTP_201_CREATED)
756
+ async def upload_file(
757
+ file: UploadFile = File(...),
758
+ current_user: dict = Depends(get_current_user)
759
+ ):
760
+ """Upload a file for messaging"""
761
+
762
+ # Validate file type
763
+ allowed_types = {
764
+ 'image': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'],
765
+ 'document': ['.pdf', '.doc', '.docx', '.txt', '.rtf'],
766
+ 'spreadsheet': ['.xls', '.xlsx', '.csv'],
767
+ 'presentation': ['.ppt', '.pptx'],
768
+ 'archive': ['.zip', '.rar', '.7z']
769
+ }
770
+
771
+ # Get file extension
772
+ file_ext = Path(file.filename).suffix.lower()
773
+
774
+ # Check if file type is allowed
775
+ is_allowed = False
776
+ file_category = None
777
+ for category, extensions in allowed_types.items():
778
+ if file_ext in extensions:
779
+ is_allowed = True
780
+ file_category = category
781
+ break
782
+
783
+ if not is_allowed:
784
+ raise HTTPException(
785
+ status_code=status.HTTP_400_BAD_REQUEST,
786
+ detail=f"File type {file_ext} is not allowed. Allowed types: {', '.join([ext for exts in allowed_types.values() for ext in exts])}"
787
+ )
788
+
789
+ # Check file size (max 10MB)
790
+ max_size = 10 * 1024 * 1024 # 10MB
791
+ if file.size and file.size > max_size:
792
+ raise HTTPException(
793
+ status_code=status.HTTP_400_BAD_REQUEST,
794
+ detail=f"File size exceeds maximum limit of 10MB"
795
+ )
796
+
797
+ # Create uploads directory if it doesn't exist
798
+ upload_dir = Path("uploads")
799
+ upload_dir.mkdir(exist_ok=True)
800
+
801
+ # Create category subdirectory
802
+ category_dir = upload_dir / file_category
803
+ category_dir.mkdir(exist_ok=True)
804
+
805
+ # Generate unique filename
806
+ unique_filename = f"{uuid.uuid4()}{file_ext}"
807
+ file_path = category_dir / unique_filename
808
+
809
+ try:
810
+ # Save file
811
+ with open(file_path, "wb") as buffer:
812
+ content = await file.read()
813
+ buffer.write(content)
814
+
815
+ # Return file info
816
+ return {
817
+ "filename": file.filename,
818
+ "file_url": f"/uploads/{file_category}/{unique_filename}",
819
+ "file_size": len(content),
820
+ "file_type": file_category,
821
+ "message_type": MessageType.IMAGE if file_category == 'image' else MessageType.FILE
822
+ }
823
+
824
+ except Exception as e:
825
+ # Clean up file if save fails
826
+ if file_path.exists():
827
+ file_path.unlink()
828
+ raise HTTPException(
829
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
830
+ detail=f"Failed to upload file: {str(e)}"
831
+ )
832
+
833
+ # --- STATIC FILE SERVING ---
834
+ @router.get("/uploads/{category}/{filename}")
835
+ async def serve_file(category: str, filename: str):
836
+ """Serve uploaded files"""
837
+ import os
838
+
839
+ # Get the current working directory and construct absolute path
840
+ current_dir = os.getcwd()
841
+ file_path = Path(current_dir) / "uploads" / category / filename
842
+
843
+ print(f"🔍 Looking for file: {file_path}")
844
+ print(f"📁 File exists: {file_path.exists()}")
845
+
846
+ if not file_path.exists():
847
+ raise HTTPException(
848
+ status_code=status.HTTP_404_NOT_FOUND,
849
+ detail=f"File not found: {file_path}"
850
+ )
851
+
852
+ # Determine content type based on file extension
853
+ ext = file_path.suffix.lower()
854
+ content_types = {
855
+ '.jpg': 'image/jpeg',
856
+ '.jpeg': 'image/jpeg',
857
+ '.png': 'image/png',
858
+ '.gif': 'image/gif',
859
+ '.bmp': 'image/bmp',
860
+ '.webp': 'image/webp',
861
+ '.pdf': 'application/pdf',
862
+ '.doc': 'application/msword',
863
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
864
+ '.txt': 'text/plain',
865
+ '.rtf': 'application/rtf',
866
+ '.xls': 'application/vnd.ms-excel',
867
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
868
+ '.csv': 'text/csv',
869
+ '.ppt': 'application/vnd.ms-powerpoint',
870
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
871
+ '.zip': 'application/zip',
872
+ '.rar': 'application/x-rar-compressed',
873
+ '.7z': 'application/x-7z-compressed'
874
+ }
875
+
876
+ content_type = content_types.get(ext, 'application/octet-stream')
877
+
878
+ try:
879
+ # Read and return file content
880
+ with open(file_path, "rb") as f:
881
+ content = f.read()
882
+
883
+ from fastapi.responses import Response
884
+ return Response(
885
+ content=content,
886
+ media_type=content_type,
887
+ headers={
888
+ "Content-Disposition": f"inline; filename={filename}",
889
+ "Cache-Control": "public, max-age=31536000"
890
+ }
891
+ )
892
+ except Exception as e:
893
+ print(f"❌ Error reading file: {e}")
894
+ raise HTTPException(
895
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
896
+ detail=f"Error reading file: {str(e)}"
897
+ )
api/routes/patients.py ADDED
@@ -0,0 +1,1146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Query, status, Body
2
+ from db.mongo import patients_collection, db # Added db import
3
+ from core.security import get_current_user
4
+ from utils.db import create_indexes
5
+ from utils.helpers import calculate_age, standardize_language
6
+ from models.entities import Note, PatientCreate
7
+ from models.schemas import PatientListResponse # Fixed import
8
+ from api.services.fhir_integration import HAPIFHIRIntegrationService
9
+ from datetime import datetime
10
+ from bson import ObjectId
11
+ from bson.errors import InvalidId
12
+ from typing import Optional, List, Dict, Any
13
+ from pymongo import UpdateOne, DeleteOne
14
+ from pymongo.errors import BulkWriteError
15
+ import json
16
+ from pathlib import Path
17
+ import glob
18
+ import uuid
19
+ import re
20
+ import logging
21
+ import time
22
+ import os
23
+ from pydantic import BaseModel, Field
24
+ from motor.motor_asyncio import AsyncIOMotorClient
25
+
26
+ # Configure logging
27
+ logging.basicConfig(
28
+ level=logging.INFO,
29
+ format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
30
+ )
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter()
34
+
35
+ # Configuration
36
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent
37
+ SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
38
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
39
+
40
+ # Pydantic models for update validation
41
+ class ConditionUpdate(BaseModel):
42
+ id: Optional[str] = None
43
+ code: Optional[str] = None
44
+ status: Optional[str] = None
45
+ onset_date: Optional[str] = None
46
+ recorded_date: Optional[str] = None
47
+ verification_status: Optional[str] = None
48
+ notes: Optional[str] = None
49
+
50
+ class MedicationUpdate(BaseModel):
51
+ id: Optional[str] = None
52
+ name: Optional[str] = None
53
+ status: Optional[str] = None
54
+ prescribed_date: Optional[str] = None
55
+ requester: Optional[str] = None
56
+ dosage: Optional[str] = None
57
+
58
+ class EncounterUpdate(BaseModel):
59
+ id: Optional[str] = None
60
+ type: Optional[str] = None
61
+ status: Optional[str] = None
62
+ period: Optional[Dict[str, str]] = None
63
+ service_provider: Optional[str] = None
64
+
65
+ class NoteUpdate(BaseModel):
66
+ id: Optional[str] = None
67
+ title: Optional[str] = None
68
+ date: Optional[str] = None
69
+ author: Optional[str] = None
70
+ content: Optional[str] = None
71
+
72
+ class PatientUpdate(BaseModel):
73
+ full_name: Optional[str] = None
74
+ gender: Optional[str] = None
75
+ date_of_birth: Optional[str] = None
76
+ address: Optional[str] = None
77
+ city: Optional[str] = None
78
+ state: Optional[str] = None
79
+ postal_code: Optional[str] = None
80
+ country: Optional[str] = None
81
+ marital_status: Optional[str] = None
82
+ language: Optional[str] = None
83
+ conditions: Optional[List[ConditionUpdate]] = None
84
+ medications: Optional[List[MedicationUpdate]] = None
85
+ encounters: Optional[List[EncounterUpdate]] = None
86
+ notes: Optional[List[NoteUpdate]] = None
87
+
88
+ @router.get("/debug/count")
89
+ async def debug_patient_count():
90
+ """Debug endpoint to verify patient counts"""
91
+ try:
92
+ total = await patients_collection.count_documents({})
93
+ synthea = await patients_collection.count_documents({"source": "synthea"})
94
+ manual = await patients_collection.count_documents({"source": "manual"})
95
+ return {
96
+ "total": total,
97
+ "synthea": synthea,
98
+ "manual": manual,
99
+ "message": f"Found {total} total patients ({synthea} from synthea, {manual} manual)"
100
+ }
101
+ except Exception as e:
102
+ logger.error(f"Error counting patients: {str(e)}")
103
+ raise HTTPException(
104
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
105
+ detail=f"Error counting patients: {str(e)}"
106
+ )
107
+
108
+ @router.post("/patients", status_code=status.HTTP_201_CREATED)
109
+ async def create_patient(
110
+ patient_data: PatientCreate,
111
+ current_user: dict = Depends(get_current_user)
112
+ ):
113
+ """Create a new patient in the database"""
114
+ logger.info(f"Creating new patient by user {current_user.get('email')}")
115
+
116
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
117
+ logger.warning(f"Unauthorized create attempt by {current_user.get('email')}")
118
+ raise HTTPException(
119
+ status_code=status.HTTP_403_FORBIDDEN,
120
+ detail="Only administrators and doctors can create patients"
121
+ )
122
+
123
+ try:
124
+ # Prepare the patient document
125
+ patient_doc = patient_data.dict()
126
+ now = datetime.utcnow().isoformat()
127
+
128
+ # Add system-generated fields
129
+ patient_doc.update({
130
+ "fhir_id": str(uuid.uuid4()),
131
+ "import_date": now,
132
+ "last_updated": now,
133
+ "source": "manual",
134
+ "created_by": current_user.get('email')
135
+ })
136
+
137
+ # Ensure arrays exist even if empty
138
+ for field in ['conditions', 'medications', 'encounters', 'notes']:
139
+ if field not in patient_doc:
140
+ patient_doc[field] = []
141
+
142
+ # Insert the patient document
143
+ result = await patients_collection.insert_one(patient_doc)
144
+
145
+ # Return the created patient with the generated ID
146
+ created_patient = await patients_collection.find_one(
147
+ {"_id": result.inserted_id}
148
+ )
149
+
150
+ if not created_patient:
151
+ logger.error("Failed to retrieve created patient")
152
+ raise HTTPException(
153
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
154
+ detail="Failed to retrieve created patient"
155
+ )
156
+
157
+ created_patient["id"] = str(created_patient["_id"])
158
+ del created_patient["_id"]
159
+
160
+ logger.info(f"Successfully created patient {created_patient['fhir_id']}")
161
+ return created_patient
162
+
163
+ except Exception as e:
164
+ logger.error(f"Failed to create patient: {str(e)}")
165
+ raise HTTPException(
166
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
167
+ detail=f"Failed to create patient: {str(e)}"
168
+ )
169
+
170
+ @router.delete("/patients/{patient_id}", status_code=status.HTTP_204_NO_CONTENT)
171
+ async def delete_patient(
172
+ patient_id: str,
173
+ current_user: dict = Depends(get_current_user)
174
+ ):
175
+ """Delete a patient from the database"""
176
+ logger.info(f"Deleting patient {patient_id} by user {current_user.get('email')}")
177
+
178
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
179
+ logger.warning(f"Unauthorized delete attempt by {current_user.get('email')}")
180
+ raise HTTPException(
181
+ status_code=status.HTTP_403_FORBIDDEN,
182
+ detail="Only administrators can delete patients"
183
+ )
184
+
185
+ try:
186
+ # Build the query based on whether patient_id is a valid ObjectId
187
+ query = {"fhir_id": patient_id}
188
+ if ObjectId.is_valid(patient_id):
189
+ query = {
190
+ "$or": [
191
+ {"_id": ObjectId(patient_id)},
192
+ {"fhir_id": patient_id}
193
+ ]
194
+ }
195
+
196
+ # Check if patient exists
197
+ patient = await patients_collection.find_one(query)
198
+
199
+ if not patient:
200
+ logger.warning(f"Patient not found for deletion: {patient_id}")
201
+ raise HTTPException(
202
+ status_code=status.HTTP_404_NOT_FOUND,
203
+ detail="Patient not found"
204
+ )
205
+
206
+ # Perform deletion
207
+ result = await patients_collection.delete_one(query)
208
+
209
+ if result.deleted_count == 0:
210
+ logger.error(f"Failed to delete patient {patient_id}")
211
+ raise HTTPException(
212
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
213
+ detail="Failed to delete patient"
214
+ )
215
+
216
+ logger.info(f"Successfully deleted patient {patient_id}")
217
+ return None
218
+
219
+ except HTTPException:
220
+ raise
221
+ except Exception as e:
222
+ logger.error(f"Failed to delete patient {patient_id}: {str(e)}")
223
+ raise HTTPException(
224
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
225
+ detail=f"Failed to delete patient: {str(e)}"
226
+ )
227
+
228
+ async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
229
+ logger.debug(f"Processing patient from file: {file_path}")
230
+ patient_data = {}
231
+ notes = []
232
+ conditions = []
233
+ medications = []
234
+ encounters = []
235
+
236
+ # Validate bundle structure
237
+ if not isinstance(bundle, dict) or 'entry' not in bundle:
238
+ logger.error(f"Invalid FHIR bundle structure in {file_path}")
239
+ return None
240
+
241
+ for entry in bundle.get('entry', []):
242
+ resource = entry.get('resource', {})
243
+ resource_type = resource.get('resourceType')
244
+
245
+ if not resource_type:
246
+ logger.warning(f"Skipping entry with missing resourceType in {file_path}")
247
+ continue
248
+
249
+ try:
250
+ if resource_type == 'Patient':
251
+ name = resource.get('name', [{}])[0]
252
+ address = resource.get('address', [{}])[0]
253
+
254
+ # Construct full name and remove numbers
255
+ raw_full_name = f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip()
256
+ clean_full_name = re.sub(r'\d+', '', raw_full_name).strip()
257
+
258
+ patient_data = {
259
+ 'fhir_id': resource.get('id'),
260
+ 'full_name': clean_full_name,
261
+ 'gender': resource.get('gender', 'unknown'),
262
+ 'date_of_birth': resource.get('birthDate', ''),
263
+ 'address': ' '.join(address.get('line', [''])),
264
+ 'city': address.get('city', ''),
265
+ 'state': address.get('state', ''),
266
+ 'postal_code': address.get('postalCode', ''),
267
+ 'country': address.get('country', ''),
268
+ 'marital_status': resource.get('maritalStatus', {}).get('text', ''),
269
+ 'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
270
+ 'source': 'synthea',
271
+ 'last_updated': datetime.utcnow().isoformat()
272
+ }
273
+
274
+ elif resource_type == 'Encounter':
275
+ encounter = {
276
+ 'id': resource.get('id'),
277
+ 'type': resource.get('type', [{}])[0].get('text', ''),
278
+ 'status': resource.get('status'),
279
+ 'period': resource.get('period', {}),
280
+ 'service_provider': resource.get('serviceProvider', {}).get('display', '')
281
+ }
282
+ encounters.append(encounter)
283
+
284
+ for note in resource.get('note', []):
285
+ if note.get('text'):
286
+ notes.append({
287
+ 'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
288
+ 'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
289
+ 'text': note.get('text'),
290
+ 'context': f"Encounter: {encounter.get('type')}",
291
+ 'author': 'System Generated'
292
+ })
293
+
294
+ elif resource_type == 'Condition':
295
+ conditions.append({
296
+ 'id': resource.get('id'),
297
+ 'code': resource.get('code', {}).get('text', ''),
298
+ 'status': resource.get('clinicalStatus', {}).get('text', ''),
299
+ 'onset_date': resource.get('onsetDateTime'),
300
+ 'recorded_date': resource.get('recordedDate'),
301
+ 'verification_status': resource.get('verificationStatus', {}).get('text', '')
302
+ })
303
+
304
+ elif resource_type == 'MedicationRequest':
305
+ medications.append({
306
+ 'id': resource.get('id'),
307
+ 'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
308
+ 'status': resource.get('status'),
309
+ 'prescribed_date': resource.get('authoredOn'),
310
+ 'requester': resource.get('requester', {}).get('display', ''),
311
+ 'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
312
+ })
313
+
314
+ except Exception as e:
315
+ logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
316
+ continue
317
+
318
+ if patient_data:
319
+ patient_data.update({
320
+ 'notes': notes,
321
+ 'conditions': conditions,
322
+ 'medications': medications,
323
+ 'encounters': encounters,
324
+ 'import_date': datetime.utcnow().isoformat()
325
+ })
326
+ logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
327
+ return patient_data
328
+ logger.warning(f"No valid patient data found in {file_path}")
329
+ return None
330
+
331
+ @router.post("/import", status_code=status.HTTP_201_CREATED)
332
+ async def import_patients(
333
+ limit: int = Query(100, ge=1, le=1000),
334
+ current_user: dict = Depends(get_current_user)
335
+ ):
336
+ request_id = str(uuid.uuid4())
337
+ logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
338
+ start_time = time.time()
339
+
340
+ if current_user.get('role') not in ['admin', 'doctor']:
341
+ logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
342
+ raise HTTPException(
343
+ status_code=status.HTTP_403_FORBIDDEN,
344
+ detail="Only administrators and doctors can import data"
345
+ )
346
+
347
+ try:
348
+ await create_indexes()
349
+
350
+ if not SYNTHEA_DATA_DIR.exists():
351
+ logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
352
+ raise HTTPException(
353
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
354
+ detail="Data directory not found"
355
+ )
356
+
357
+ # Filter out non-patient files
358
+ files = [
359
+ f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
360
+ if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
361
+ ]
362
+ if not files:
363
+ logger.warning("No valid patient JSON files found in synthea data directory")
364
+ return {
365
+ "status": "success",
366
+ "message": "No patient data files found",
367
+ "imported": 0,
368
+ "request_id": request_id
369
+ }
370
+
371
+ operations = []
372
+ imported = 0
373
+ errors = []
374
+
375
+ for file_path in files[:limit]:
376
+ try:
377
+ logger.debug(f"Processing file: {file_path}")
378
+
379
+ # Check file accessibility
380
+ if not os.path.exists(file_path):
381
+ logger.error(f"File not found: {file_path}")
382
+ errors.append(f"File not found: {file_path}")
383
+ continue
384
+
385
+ # Check file size
386
+ file_size = os.path.getsize(file_path)
387
+ if file_size == 0:
388
+ logger.warning(f"Empty file: {file_path}")
389
+ errors.append(f"Empty file: {file_path}")
390
+ continue
391
+
392
+ with open(file_path, 'r', encoding='utf-8') as f:
393
+ try:
394
+ bundle = json.load(f)
395
+ except json.JSONDecodeError as je:
396
+ logger.error(f"Invalid JSON in {file_path}: {str(je)}")
397
+ errors.append(f"Invalid JSON in {file_path}: {str(je)}")
398
+ continue
399
+
400
+ patient = await process_synthea_patient(bundle, file_path)
401
+ if patient:
402
+ if not patient.get('fhir_id'):
403
+ logger.warning(f"Missing FHIR ID in patient data from {file_path}")
404
+ errors.append(f"Missing FHIR ID in {file_path}")
405
+ continue
406
+
407
+ operations.append(UpdateOne(
408
+ {"fhir_id": patient['fhir_id']},
409
+ {"$setOnInsert": patient},
410
+ upsert=True
411
+ ))
412
+ imported += 1
413
+ else:
414
+ logger.warning(f"No valid patient data in {file_path}")
415
+ errors.append(f"No valid patient data in {file_path}")
416
+
417
+ except Exception as e:
418
+ logger.error(f"Error processing {file_path}: {str(e)}")
419
+ errors.append(f"Error in {file_path}: {str(e)}")
420
+ continue
421
+
422
+ response = {
423
+ "status": "success",
424
+ "imported": imported,
425
+ "errors": errors,
426
+ "request_id": request_id,
427
+ "duration_seconds": time.time() - start_time
428
+ }
429
+
430
+ if operations:
431
+ try:
432
+ result = await patients_collection.bulk_write(operations, ordered=False)
433
+ response.update({
434
+ "upserted": result.upserted_count,
435
+ "existing": len(operations) - result.upserted_count
436
+ })
437
+ logger.info(f"Import request {request_id} completed: {imported} patients processed, "
438
+ f"{result.upserted_count} upserted, {len(errors)} errors")
439
+ except BulkWriteError as bwe:
440
+ logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
441
+ response.update({
442
+ "upserted": bwe.details.get('nUpserted', 0),
443
+ "existing": len(operations) - bwe.details.get('nUpserted', 0),
444
+ "write_errors": [
445
+ f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
446
+ ]
447
+ })
448
+ logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
449
+ f"{response['upserted']} upserted, {len(errors)} errors")
450
+ except Exception as e:
451
+ logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
452
+ raise HTTPException(
453
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
454
+ detail=f"Database operation failed: {str(e)}"
455
+ )
456
+ else:
457
+ logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
458
+ response["message"] = "No new patients found to import"
459
+
460
+ return response
461
+
462
+ except HTTPException:
463
+ raise
464
+ except Exception as e:
465
+ logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
466
+ raise HTTPException(
467
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
468
+ detail=f"Import failed: {str(e)}"
469
+ )
470
+
471
+ @router.post("/patients/import-ehr", status_code=status.HTTP_201_CREATED)
472
+ async def import_ehr_patients(
473
+ ehr_data: List[dict],
474
+ ehr_system: str = Query(..., description="Name of the EHR system"),
475
+ current_user: dict = Depends(get_current_user),
476
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
477
+ ):
478
+ """Import patients from external EHR system"""
479
+ logger.info(f"Importing {len(ehr_data)} patients from EHR system: {ehr_system}")
480
+
481
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
482
+ logger.warning(f"Unauthorized EHR import attempt by {current_user.get('email')}")
483
+ raise HTTPException(
484
+ status_code=status.HTTP_403_FORBIDDEN,
485
+ detail="Only administrators and doctors can import EHR patients"
486
+ )
487
+
488
+ try:
489
+ imported_patients = []
490
+ skipped_patients = []
491
+
492
+ for patient_data in ehr_data:
493
+ # Check if patient already exists by multiple criteria
494
+ existing_patient = await patients_collection.find_one({
495
+ "$or": [
496
+ {"ehr_id": patient_data.get("ehr_id"), "ehr_system": ehr_system},
497
+ {"full_name": patient_data.get("full_name"), "date_of_birth": patient_data.get("date_of_birth")},
498
+ {"national_id": patient_data.get("national_id")} if patient_data.get("national_id") else {}
499
+ ]
500
+ })
501
+
502
+ if existing_patient:
503
+ skipped_patients.append(patient_data.get("full_name", "Unknown"))
504
+ logger.info(f"Patient {patient_data.get('full_name', 'Unknown')} already exists, skipping...")
505
+ continue
506
+
507
+ # Prepare patient document for EHR import
508
+ patient_doc = {
509
+ "full_name": patient_data.get("full_name"),
510
+ "date_of_birth": patient_data.get("date_of_birth"),
511
+ "gender": patient_data.get("gender"),
512
+ "address": patient_data.get("address"),
513
+ "national_id": patient_data.get("national_id"),
514
+ "blood_type": patient_data.get("blood_type"),
515
+ "allergies": patient_data.get("allergies", []),
516
+ "chronic_conditions": patient_data.get("chronic_conditions", []),
517
+ "medications": patient_data.get("medications", []),
518
+ "emergency_contact_name": patient_data.get("emergency_contact_name"),
519
+ "emergency_contact_phone": patient_data.get("emergency_contact_phone"),
520
+ "insurance_provider": patient_data.get("insurance_provider"),
521
+ "insurance_policy_number": patient_data.get("insurance_policy_number"),
522
+ "contact": patient_data.get("contact"),
523
+ "source": "ehr",
524
+ "ehr_id": patient_data.get("ehr_id"),
525
+ "ehr_system": ehr_system,
526
+ "status": "active",
527
+ "registration_date": datetime.now(),
528
+ "created_by": current_user.get('email'),
529
+ "created_at": datetime.now(),
530
+ "updated_at": datetime.now()
531
+ }
532
+
533
+ # Insert patient
534
+ result = await patients_collection.insert_one(patient_doc)
535
+ imported_patients.append(patient_data.get("full_name", "Unknown"))
536
+
537
+ logger.info(f"Successfully imported {len(imported_patients)} patients, skipped {len(skipped_patients)}")
538
+
539
+ return {
540
+ "message": f"Successfully imported {len(imported_patients)} patients from {ehr_system}",
541
+ "imported_count": len(imported_patients),
542
+ "skipped_count": len(skipped_patients),
543
+ "imported_patients": imported_patients,
544
+ "skipped_patients": skipped_patients
545
+ }
546
+
547
+ except Exception as e:
548
+ logger.error(f"Error importing EHR patients: {str(e)}")
549
+ raise HTTPException(
550
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
551
+ detail=f"Error importing EHR patients: {str(e)}"
552
+ )
553
+
554
+ @router.get("/patients/sources", response_model=List[dict])
555
+ async def get_patient_sources(
556
+ current_user: dict = Depends(get_current_user)
557
+ ):
558
+ """Get available patient sources and their counts"""
559
+ try:
560
+ # Get counts for each source
561
+ source_counts = await patients_collection.aggregate([
562
+ {
563
+ "$group": {
564
+ "_id": "$source",
565
+ "count": {"$sum": 1}
566
+ }
567
+ }
568
+ ]).to_list(length=None)
569
+
570
+ # Format the response
571
+ sources = []
572
+ for source_count in source_counts:
573
+ source_name = source_count["_id"] or "unknown"
574
+ sources.append({
575
+ "source": source_name,
576
+ "count": source_count["count"],
577
+ "label": source_name.replace("_", " ").title()
578
+ })
579
+
580
+ return sources
581
+
582
+ except Exception as e:
583
+ logger.error(f"Error getting patient sources: {str(e)}")
584
+ raise HTTPException(
585
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
586
+ detail=f"Error getting patient sources: {str(e)}"
587
+ )
588
+
589
+ @router.get("/patients", response_model=PatientListResponse)
590
+ async def get_patients(
591
+ page: int = Query(1, ge=1),
592
+ limit: int = Query(20, ge=1, le=100),
593
+ search: Optional[str] = Query(None),
594
+ source: Optional[str] = Query(None), # Filter by patient source
595
+ patient_status: Optional[str] = Query(None), # Filter by patient status
596
+ doctor_id: Optional[str] = Query(None), # Filter by assigned doctor
597
+ current_user: dict = Depends(get_current_user),
598
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
599
+ ):
600
+ """Get patients with filtering options"""
601
+ skip = (page - 1) * limit
602
+ user_id = current_user["_id"]
603
+
604
+ # Debug logging
605
+ logger.info(f"🔍 Getting patients for user: {current_user.get('email')} with roles: {current_user.get('roles', [])}")
606
+
607
+ # Build filter query
608
+ filter_query = {}
609
+
610
+ # Role-based access - apply this first
611
+ if 'admin' not in current_user.get('roles', []):
612
+ if 'doctor' in current_user.get('roles', []):
613
+ # Doctors can see all patients for now (temporarily simplified)
614
+ logger.info("👨‍⚕️ Doctor access - no restrictions applied")
615
+ pass # No restrictions for doctors
616
+ else:
617
+ # Patients can only see their own record
618
+ logger.info(f"👤 Patient access - restricting to own record: {user_id}")
619
+ filter_query["_id"] = ObjectId(user_id)
620
+
621
+ # Build additional filters
622
+ additional_filters = {}
623
+
624
+ # Add search filter
625
+ if search:
626
+ additional_filters["$or"] = [
627
+ {"full_name": {"$regex": search, "$options": "i"}},
628
+ {"national_id": {"$regex": search, "$options": "i"}},
629
+ {"ehr_id": {"$regex": search, "$options": "i"}}
630
+ ]
631
+
632
+ # Add source filter
633
+ if source:
634
+ additional_filters["source"] = source
635
+
636
+ # Add status filter
637
+ if patient_status:
638
+ additional_filters["status"] = patient_status
639
+
640
+ # Add doctor assignment filter
641
+ if doctor_id:
642
+ additional_filters["assigned_doctor_id"] = ObjectId(doctor_id)
643
+
644
+ # Combine filters
645
+ if additional_filters:
646
+ if filter_query.get("$or"):
647
+ # If we have role-based $or, we need to combine with additional filters
648
+ # Create a new $and condition
649
+ filter_query = {
650
+ "$and": [
651
+ filter_query,
652
+ additional_filters
653
+ ]
654
+ }
655
+ else:
656
+ # No role-based restrictions, just use additional filters
657
+ filter_query.update(additional_filters)
658
+
659
+ logger.info(f"🔍 Final filter query: {filter_query}")
660
+
661
+ try:
662
+ # Get total count
663
+ total = await patients_collection.count_documents(filter_query)
664
+ logger.info(f"📊 Total patients matching filter: {total}")
665
+
666
+ # Get patients with pagination
667
+ patients_cursor = patients_collection.find(filter_query).skip(skip).limit(limit)
668
+ patients = await patients_cursor.to_list(length=limit)
669
+ logger.info(f"📋 Retrieved {len(patients)} patients")
670
+
671
+ # Process patients to include doctor names and format dates
672
+ processed_patients = []
673
+ for patient in patients:
674
+ # Get assigned doctor name if exists
675
+ assigned_doctor_name = None
676
+ if patient.get("assigned_doctor_id"):
677
+ doctor = await db_client.users.find_one({"_id": patient["assigned_doctor_id"]})
678
+ if doctor:
679
+ assigned_doctor_name = doctor.get("full_name")
680
+
681
+ # Convert ObjectId to string
682
+ patient["id"] = str(patient["_id"])
683
+ del patient["_id"]
684
+
685
+ # Add assigned doctor name
686
+ patient["assigned_doctor_name"] = assigned_doctor_name
687
+
688
+ processed_patients.append(patient)
689
+
690
+ logger.info(f"✅ Returning {len(processed_patients)} processed patients")
691
+
692
+ return PatientListResponse(
693
+ patients=processed_patients,
694
+ total=total,
695
+ page=page,
696
+ limit=limit,
697
+ source_filter=source,
698
+ status_filter=patient_status
699
+ )
700
+
701
+ except Exception as e:
702
+ logger.error(f"❌ Error fetching patients: {str(e)}")
703
+ raise HTTPException(
704
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
705
+ detail=f"Error fetching patients: {str(e)}"
706
+ )
707
+
708
+ @router.get("/patients/{patient_id}", response_model=dict)
709
+ async def get_patient(patient_id: str):
710
+ logger.info(f"Retrieving patient: {patient_id}")
711
+ try:
712
+ patient = await patients_collection.find_one({
713
+ "$or": [
714
+ {"_id": ObjectId(patient_id)},
715
+ {"fhir_id": patient_id}
716
+ ]
717
+ })
718
+
719
+ if not patient:
720
+ logger.warning(f"Patient not found: {patient_id}")
721
+ raise HTTPException(
722
+ status_code=status.HTTP_404_NOT_FOUND,
723
+ detail="Patient not found"
724
+ )
725
+
726
+ response = {
727
+ "demographics": {
728
+ "id": str(patient["_id"]),
729
+ "fhir_id": patient.get("fhir_id"),
730
+ "full_name": patient.get("full_name"),
731
+ "gender": patient.get("gender"),
732
+ "date_of_birth": patient.get("date_of_birth"),
733
+ "age": calculate_age(patient.get("date_of_birth")),
734
+ "address": {
735
+ "line": patient.get("address"),
736
+ "city": patient.get("city"),
737
+ "state": patient.get("state"),
738
+ "postal_code": patient.get("postal_code"),
739
+ "country": patient.get("country")
740
+ },
741
+ "marital_status": patient.get("marital_status"),
742
+ "language": patient.get("language")
743
+ },
744
+ "clinical_data": {
745
+ "notes": patient.get("notes", []),
746
+ "conditions": patient.get("conditions", []),
747
+ "medications": patient.get("medications", []),
748
+ "encounters": patient.get("encounters", [])
749
+ },
750
+ "metadata": {
751
+ "source": patient.get("source"),
752
+ "import_date": patient.get("import_date"),
753
+ "last_updated": patient.get("last_updated")
754
+ }
755
+ }
756
+
757
+ logger.info(f"Successfully retrieved patient: {patient_id}")
758
+ return response
759
+
760
+ except ValueError as ve:
761
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
762
+ raise HTTPException(
763
+ status_code=status.HTTP_400_BAD_REQUEST,
764
+ detail="Invalid patient ID format"
765
+ )
766
+ except Exception as e:
767
+ logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
768
+ raise HTTPException(
769
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
770
+ detail=f"Failed to retrieve patient: {str(e)}"
771
+ )
772
+
773
+ @router.post("/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
774
+ async def add_note(
775
+ patient_id: str,
776
+ note: Note,
777
+ current_user: dict = Depends(get_current_user)
778
+ ):
779
+ logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
780
+ if current_user.get('role') not in ['doctor', 'admin']:
781
+ logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
782
+ raise HTTPException(
783
+ status_code=status.HTTP_403_FORBIDDEN,
784
+ detail="Only clinicians can add notes"
785
+ )
786
+
787
+ try:
788
+ note_data = note.dict()
789
+ note_data.update({
790
+ "author": current_user.get('full_name', 'System'),
791
+ "timestamp": datetime.utcnow().isoformat()
792
+ })
793
+
794
+ result = await patients_collection.update_one(
795
+ {"$or": [
796
+ {"_id": ObjectId(patient_id)},
797
+ {"fhir_id": patient_id}
798
+ ]},
799
+ {
800
+ "$push": {"notes": note_data},
801
+ "$set": {"last_updated": datetime.utcnow().isoformat()}
802
+ }
803
+ )
804
+
805
+ if result.modified_count == 0:
806
+ logger.warning(f"Patient not found for note addition: {patient_id}")
807
+ raise HTTPException(
808
+ status_code=status.HTTP_404_NOT_FOUND,
809
+ detail="Patient not found"
810
+ )
811
+
812
+ logger.info(f"Note added successfully for patient {patient_id}")
813
+ return {"status": "success", "message": "Note added"}
814
+
815
+ except ValueError as ve:
816
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
817
+ raise HTTPException(
818
+ status_code=status.HTTP_400_BAD_REQUEST,
819
+ detail="Invalid patient ID format"
820
+ )
821
+ except Exception as e:
822
+ logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
823
+ raise HTTPException(
824
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
825
+ detail=f"Failed to add note: {str(e)}"
826
+ )
827
+
828
+ @router.put("/patients/{patient_id}", status_code=status.HTTP_200_OK)
829
+ async def update_patient(
830
+ patient_id: str,
831
+ update_data: PatientUpdate,
832
+ current_user: dict = Depends(get_current_user)
833
+ ):
834
+ """Update a patient's record in the database"""
835
+ logger.info(f"Updating patient {patient_id} by user {current_user.get('email')}")
836
+
837
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
838
+ logger.warning(f"Unauthorized update attempt by {current_user.get('email')}")
839
+ raise HTTPException(
840
+ status_code=status.HTTP_403_FORBIDDEN,
841
+ detail="Only administrators and doctors can update patients"
842
+ )
843
+
844
+ try:
845
+ # Build the query based on whether patient_id is a valid ObjectId
846
+ query = {"fhir_id": patient_id}
847
+ if ObjectId.is_valid(patient_id):
848
+ query = {
849
+ "$or": [
850
+ {"_id": ObjectId(patient_id)},
851
+ {"fhir_id": patient_id}
852
+ ]
853
+ }
854
+
855
+ # Check if patient exists
856
+ patient = await patients_collection.find_one(query)
857
+ if not patient:
858
+ logger.warning(f"Patient not found for update: {patient_id}")
859
+ raise HTTPException(
860
+ status_code=status.HTTP_404_NOT_FOUND,
861
+ detail="Patient not found"
862
+ )
863
+
864
+ # Prepare update operations
865
+ update_ops = {"$set": {"last_updated": datetime.utcnow().isoformat()}}
866
+
867
+ # Handle demographic updates
868
+ demographics = {
869
+ "full_name": update_data.full_name,
870
+ "gender": update_data.gender,
871
+ "date_of_birth": update_data.date_of_birth,
872
+ "address": update_data.address,
873
+ "city": update_data.city,
874
+ "state": update_data.state,
875
+ "postal_code": update_data.postal_code,
876
+ "country": update_data.country,
877
+ "marital_status": update_data.marital_status,
878
+ "language": update_data.language
879
+ }
880
+ for key, value in demographics.items():
881
+ if value is not None:
882
+ update_ops["$set"][key] = value
883
+
884
+ # Handle array updates (conditions, medications, encounters, notes)
885
+ array_fields = {
886
+ "conditions": update_data.conditions,
887
+ "medications": update_data.medications,
888
+ "encounters": update_data.encounters,
889
+ "notes": update_data.notes
890
+ }
891
+
892
+ for field, items in array_fields.items():
893
+ if items is not None:
894
+ # Fetch existing items
895
+ existing_items = patient.get(field, [])
896
+ updated_items = []
897
+
898
+ for item in items:
899
+ item_dict = item.dict(exclude_unset=True)
900
+ if not item_dict:
901
+ continue
902
+
903
+ # Generate ID for new items
904
+ if not item_dict.get("id"):
905
+ item_dict["id"] = str(uuid.uuid4())
906
+
907
+ # Validate required fields
908
+ if field == "conditions" and not item_dict.get("code"):
909
+ raise HTTPException(
910
+ status_code=status.HTTP_400_BAD_REQUEST,
911
+ detail=f"Condition code is required for {field}"
912
+ )
913
+ if field == "medications" and not item_dict.get("name"):
914
+ raise HTTPException(
915
+ status_code=status.HTTP_400_BAD_REQUEST,
916
+ detail=f"Medication name is required for {field}"
917
+ )
918
+ if field == "encounters" and not item_dict.get("type"):
919
+ raise HTTPException(
920
+ status_code=status.HTTP_400_BAD_REQUEST,
921
+ detail=f"Encounter type is required for {field}"
922
+ )
923
+ if field == "notes" and not item_dict.get("content"):
924
+ raise HTTPException(
925
+ status_code=status.HTTP_400_BAD_REQUEST,
926
+ detail=f"Note content is required for {field}"
927
+ )
928
+
929
+ updated_items.append(item_dict)
930
+
931
+ # Replace the entire array
932
+ update_ops["$set"][field] = updated_items
933
+
934
+ # Perform the update
935
+ result = await patients_collection.update_one(query, update_ops)
936
+
937
+ if result.modified_count == 0 and result.matched_count == 0:
938
+ logger.warning(f"Patient not found for update: {patient_id}")
939
+ raise HTTPException(
940
+ status_code=status.HTTP_404_NOT_FOUND,
941
+ detail="Patient not found"
942
+ )
943
+
944
+ # Retrieve and return the updated patient
945
+ updated_patient = await patients_collection.find_one(query)
946
+ if not updated_patient:
947
+ logger.error(f"Failed to retrieve updated patient: {patient_id}")
948
+ raise HTTPException(
949
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
950
+ detail="Failed to retrieve updated patient"
951
+ )
952
+
953
+ response = {
954
+ "id": str(updated_patient["_id"]),
955
+ "fhir_id": updated_patient.get("fhir_id"),
956
+ "full_name": updated_patient.get("full_name"),
957
+ "gender": updated_patient.get("gender"),
958
+ "date_of_birth": updated_patient.get("date_of_birth"),
959
+ "address": updated_patient.get("address"),
960
+ "city": updated_patient.get("city"),
961
+ "state": updated_patient.get("state"),
962
+ "postal_code": updated_patient.get("postal_code"),
963
+ "country": updated_patient.get("country"),
964
+ "marital_status": updated_patient.get("marital_status"),
965
+ "language": updated_patient.get("language"),
966
+ "conditions": updated_patient.get("conditions", []),
967
+ "medications": updated_patient.get("medications", []),
968
+ "encounters": updated_patient.get("encounters", []),
969
+ "notes": updated_patient.get("notes", []),
970
+ "source": updated_patient.get("source"),
971
+ "import_date": updated_patient.get("import_date"),
972
+ "last_updated": updated_patient.get("last_updated")
973
+ }
974
+
975
+ logger.info(f"Successfully updated patient {patient_id}")
976
+ return response
977
+
978
+ except ValueError as ve:
979
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
980
+ raise HTTPException(
981
+ status_code=status.HTTP_400_BAD_REQUEST,
982
+ detail="Invalid patient ID format"
983
+ )
984
+ except HTTPException:
985
+ raise
986
+ except Exception as e:
987
+ logger.error(f"Failed to update patient {patient_id}: {str(e)}")
988
+ raise HTTPException(
989
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
990
+ detail=f"Failed to update patient: {str(e)}"
991
+ )
992
+
993
+ # FHIR Integration Endpoints
994
+ @router.post("/patients/import-hapi-fhir", status_code=status.HTTP_201_CREATED)
995
+ async def import_hapi_patients(
996
+ limit: int = Query(20, ge=1, le=100, description="Number of patients to import"),
997
+ current_user: dict = Depends(get_current_user)
998
+ ):
999
+ """
1000
+ Import patients from HAPI FHIR Test Server
1001
+ """
1002
+ try:
1003
+ service = HAPIFHIRIntegrationService()
1004
+ result = await service.import_patients_from_hapi(limit=limit)
1005
+
1006
+ # Create detailed message
1007
+ message_parts = []
1008
+ if result["imported_count"] > 0:
1009
+ message_parts.append(f"Successfully imported {result['imported_count']} patients")
1010
+ if result["skipped_count"] > 0:
1011
+ message_parts.append(f"Skipped {result['skipped_count']} duplicate patients")
1012
+ if result["errors"]:
1013
+ message_parts.append(f"Encountered {len(result['errors'])} errors")
1014
+
1015
+ message = ". ".join(message_parts) + " from HAPI FHIR"
1016
+
1017
+ return {
1018
+ "message": message,
1019
+ "imported_count": result["imported_count"],
1020
+ "skipped_count": result["skipped_count"],
1021
+ "total_found": result["total_found"],
1022
+ "imported_patients": result["imported_patients"],
1023
+ "skipped_patients": result["skipped_patients"],
1024
+ "errors": result["errors"],
1025
+ "source": "hapi_fhir"
1026
+ }
1027
+
1028
+ except Exception as e:
1029
+ logger.error(f"Error importing HAPI FHIR patients: {str(e)}")
1030
+ raise HTTPException(
1031
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1032
+ detail=f"Failed to import patients from HAPI FHIR: {str(e)}"
1033
+ )
1034
+
1035
+ @router.post("/patients/sync-patient/{patient_id}")
1036
+ async def sync_patient_data(
1037
+ patient_id: str,
1038
+ current_user: dict = Depends(get_current_user)
1039
+ ):
1040
+ """
1041
+ Sync a specific patient's data from HAPI FHIR
1042
+ """
1043
+ try:
1044
+ service = HAPIFHIRIntegrationService()
1045
+ success = await service.sync_patient_data(patient_id)
1046
+
1047
+ if success:
1048
+ return {
1049
+ "message": f"Successfully synced patient {patient_id} from HAPI FHIR",
1050
+ "patient_id": patient_id,
1051
+ "success": True
1052
+ }
1053
+ else:
1054
+ raise HTTPException(
1055
+ status_code=status.HTTP_404_NOT_FOUND,
1056
+ detail=f"Patient {patient_id} not found in HAPI FHIR or sync failed"
1057
+ )
1058
+
1059
+ except HTTPException:
1060
+ raise
1061
+ except Exception as e:
1062
+ logger.error(f"Error syncing patient {patient_id}: {str(e)}")
1063
+ raise HTTPException(
1064
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1065
+ detail=f"Failed to sync patient: {str(e)}"
1066
+ )
1067
+
1068
+ @router.get("/patients/hapi-fhir/patients")
1069
+ async def get_hapi_patients(
1070
+ limit: int = Query(50, ge=1, le=200, description="Number of patients to fetch"),
1071
+ current_user: dict = Depends(get_current_user)
1072
+ ):
1073
+ """
1074
+ Get patients from HAPI FHIR without importing them
1075
+ """
1076
+ try:
1077
+ service = HAPIFHIRIntegrationService()
1078
+ patients = await service.get_hapi_patients(limit=limit)
1079
+
1080
+ return {
1081
+ "patients": patients,
1082
+ "count": len(patients),
1083
+ "source": "hapi_fhir"
1084
+ }
1085
+
1086
+ except Exception as e:
1087
+ logger.error(f"Error fetching HAPI FHIR patients: {str(e)}")
1088
+ raise HTTPException(
1089
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1090
+ detail=f"Failed to fetch patients from HAPI FHIR: {str(e)}"
1091
+ )
1092
+
1093
+ @router.get("/patients/hapi-fhir/patients/{patient_id}")
1094
+ async def get_hapi_patient_details(
1095
+ patient_id: str,
1096
+ current_user: dict = Depends(get_current_user)
1097
+ ):
1098
+ """
1099
+ Get detailed information for a specific HAPI FHIR patient
1100
+ """
1101
+ try:
1102
+ service = HAPIFHIRIntegrationService()
1103
+ patient_details = await service.get_hapi_patient_details(patient_id)
1104
+
1105
+ if not patient_details:
1106
+ raise HTTPException(
1107
+ status_code=status.HTTP_404_NOT_FOUND,
1108
+ detail=f"Patient {patient_id} not found in HAPI FHIR"
1109
+ )
1110
+
1111
+ return patient_details
1112
+
1113
+ except HTTPException:
1114
+ raise
1115
+ except Exception as e:
1116
+ logger.error(f"Error fetching HAPI FHIR patient details: {str(e)}")
1117
+ raise HTTPException(
1118
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1119
+ detail=f"Failed to fetch patient details from HAPI FHIR: {str(e)}"
1120
+ )
1121
+
1122
+ @router.get("/patients/hapi-fhir/statistics")
1123
+ async def get_hapi_statistics(
1124
+ current_user: dict = Depends(get_current_user)
1125
+ ):
1126
+ """
1127
+ Get statistics about HAPI FHIR imported patients
1128
+ """
1129
+ try:
1130
+ service = HAPIFHIRIntegrationService()
1131
+ stats = await service.get_patient_statistics()
1132
+
1133
+ return {
1134
+ "statistics": stats,
1135
+ "source": "hapi_fhir"
1136
+ }
1137
+
1138
+ except Exception as e:
1139
+ logger.error(f"Error getting HAPI FHIR statistics: {str(e)}")
1140
+ raise HTTPException(
1141
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1142
+ detail=f"Failed to get HAPI FHIR statistics: {str(e)}"
1143
+ )
1144
+
1145
+ # Export the router as 'patients' for api.__init__.py
1146
+ patients = router
api/routes/pdf.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Response
2
+ from db.mongo import patients_collection
3
+ from core.security import get_current_user
4
+ from utils.helpers import calculate_age, escape_latex_special_chars, hyphenate_long_strings, format_timestamp
5
+ from datetime import datetime
6
+ from bson import ObjectId
7
+ from bson.errors import InvalidId
8
+ import os
9
+ import subprocess
10
+ from tempfile import TemporaryDirectory
11
+ from string import Template
12
+ import logging
13
+
14
+ # Configure logging
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
18
+ )
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter()
22
+
23
+ @router.get("/{patient_id}/pdf", response_class=Response)
24
+ async def generate_patient_pdf(patient_id: str, current_user: dict = Depends(get_current_user)):
25
+ # Suppress logging for this route
26
+ logger.setLevel(logging.CRITICAL)
27
+
28
+ try:
29
+ if current_user.get('role') not in ['doctor', 'admin']:
30
+ raise HTTPException(status_code=403, detail="Only clinicians can generate patient PDFs")
31
+
32
+ # Determine if patient_id is ObjectId or fhir_id
33
+ try:
34
+ obj_id = ObjectId(patient_id)
35
+ query = {"$or": [{"_id": obj_id}, {"fhir_id": patient_id}]}
36
+ except InvalidId:
37
+ query = {"fhir_id": patient_id}
38
+
39
+ patient = await patients_collection.find_one(query)
40
+ if not patient:
41
+ raise HTTPException(status_code=404, detail="Patient not found")
42
+
43
+ # Prepare table content with proper LaTeX formatting
44
+ def prepare_table_content(items, columns, default_message):
45
+ if not items:
46
+ return f"\\multicolumn{{{columns}}}{{l}}{{{default_message}}} \\\\"
47
+
48
+ content = []
49
+ for item in items:
50
+ row = []
51
+ for field in item:
52
+ value = item.get(field, "") or ""
53
+ row.append(escape_latex_special_chars(hyphenate_long_strings(value)))
54
+ content.append(" & ".join(row) + " \\\\")
55
+ return "\n".join(content)
56
+
57
+ # Notes table
58
+ notes = patient.get("notes", [])
59
+ notes_content = prepare_table_content(
60
+ [{
61
+ "date": format_timestamp(n.get("date", "")),
62
+ "type": n.get("type", ""),
63
+ "text": n.get("text", "")
64
+ } for n in notes],
65
+ 3,
66
+ "No notes available"
67
+ )
68
+
69
+ # Conditions table
70
+ conditions = patient.get("conditions", [])
71
+ conditions_content = prepare_table_content(
72
+ [{
73
+ "id": c.get("id", ""),
74
+ "code": c.get("code", ""),
75
+ "status": c.get("status", ""),
76
+ "onset": format_timestamp(c.get("onset_date", "")),
77
+ "verification": c.get("verification_status", "")
78
+ } for c in conditions],
79
+ 5,
80
+ "No conditions available"
81
+ )
82
+
83
+ # Medications table
84
+ medications = patient.get("medications", [])
85
+ medications_content = prepare_table_content(
86
+ [{
87
+ "id": m.get("id", ""),
88
+ "name": m.get("name", ""),
89
+ "status": m.get("status", ""),
90
+ "date": format_timestamp(m.get("prescribed_date", "")),
91
+ "dosage": m.get("dosage", "")
92
+ } for m in medications],
93
+ 5,
94
+ "No medications available"
95
+ )
96
+
97
+ # Encounters table
98
+ encounters = patient.get("encounters", [])
99
+ encounters_content = prepare_table_content(
100
+ [{
101
+ "id": e.get("id", ""),
102
+ "type": e.get("type", ""),
103
+ "status": e.get("status", ""),
104
+ "start": format_timestamp(e.get("period", {}).get("start", "")),
105
+ "provider": e.get("service_provider", "")
106
+ } for e in encounters],
107
+ 5,
108
+ "No encounters available"
109
+ )
110
+
111
+ # LaTeX template with improved table formatting
112
+ latex_template = Template(r"""
113
+ \documentclass[a4paper,12pt]{article}
114
+ \usepackage[utf8]{inputenc}
115
+ \usepackage[T1]{fontenc}
116
+ \usepackage{geometry}
117
+ \geometry{margin=1in}
118
+ \usepackage{booktabs,longtable,fancyhdr}
119
+ \usepackage{array}
120
+ \usepackage{microtype}
121
+ \microtypesetup{expansion=false}
122
+ \setlength{\headheight}{14.5pt}
123
+ \pagestyle{fancy}
124
+ \fancyhf{}
125
+ \fancyhead[L]{Patient Report}
126
+ \fancyhead[R]{Generated: \today}
127
+ \fancyfoot[C]{\thepage}
128
+ \begin{document}
129
+ \begin{center}
130
+ \Large\textbf{Patient Medical Report} \\
131
+ \vspace{0.2cm}
132
+ \textit{Generated on $generated_on}
133
+ \end{center}
134
+ \section*{Demographics}
135
+ \begin{itemize}
136
+ \item \textbf{FHIR ID:} $fhir_id
137
+ \item \textbf{Full Name:} $full_name
138
+ \item \textbf{Gender:} $gender
139
+ \item \textbf{Date of Birth:} $dob
140
+ \item \textbf{Age:} $age
141
+ \item \textbf{Address:} $address
142
+ \item \textbf{Marital Status:} $marital_status
143
+ \item \textbf{Language:} $language
144
+ \end{itemize}
145
+ \section*{Clinical Notes}
146
+ \begin{longtable}[l]{>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{6.5cm}}
147
+ \caption{Clinical Notes} \\
148
+ \toprule
149
+ \textbf{Date} & \textbf{Type} & \textbf{Text} \\
150
+ \midrule
151
+ $notes
152
+ \bottomrule
153
+ \end{longtable}
154
+ \section*{Conditions}
155
+ \begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
156
+ \caption{Conditions} \\
157
+ \toprule
158
+ \textbf{ID} & \textbf{Code} & \textbf{Status} & \textbf{Onset} & \textbf{Verification} \\
159
+ \midrule
160
+ $conditions
161
+ \bottomrule
162
+ \end{longtable}
163
+ \section*{Medications}
164
+ \begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{4cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
165
+ \caption{Medications} \\
166
+ \toprule
167
+ \textbf{ID} & \textbf{Name} & \textbf{Status} & \textbf{Date} & \textbf{Dosage} \\
168
+ \midrule
169
+ $medications
170
+ \bottomrule
171
+ \end{longtable}
172
+ \section*{Encounters}
173
+ \begin{longtable}[l]{>{\raggedright\arraybackslash}p{2.5cm}>{\raggedright\arraybackslash}p{4.5cm}>{\raggedright\arraybackslash}p{2.5cm}>{\raggedright\arraybackslash}p{4.5cm}>{\raggedright\arraybackslash}p{3.5cm}}
174
+ \caption{Encounters} \\
175
+ \toprule
176
+ \textbf{ID} & \textbf{Type} & \textbf{Status} & \textbf{Start} & \textbf{Provider} \\
177
+ \midrule
178
+ $encounters
179
+ \bottomrule
180
+ \end{longtable}
181
+ \end{document}
182
+ """)
183
+
184
+ # Set the generated_on date to 02:54 PM CET, May 17, 2025
185
+ generated_on = datetime.strptime("2025-05-17 14:54:00+02:00", "%Y-%m-%d %H:%M:%S%z").strftime("%A, %B %d, %Y at %I:%M %p %Z")
186
+
187
+ latex_filled = latex_template.substitute(
188
+ generated_on=generated_on,
189
+ fhir_id=escape_latex_special_chars(hyphenate_long_strings(patient.get("fhir_id", "") or "")),
190
+ full_name=escape_latex_special_chars(patient.get("full_name", "") or ""),
191
+ gender=escape_latex_special_chars(patient.get("gender", "") or ""),
192
+ dob=escape_latex_special_chars(patient.get("date_of_birth", "") or ""),
193
+ age=escape_latex_special_chars(str(calculate_age(patient.get("date_of_birth", "")) or "N/A")),
194
+ address=escape_latex_special_chars(", ".join(filter(None, [
195
+ patient.get("address", ""),
196
+ patient.get("city", ""),
197
+ patient.get("state", ""),
198
+ patient.get("postal_code", ""),
199
+ patient.get("country", "")
200
+ ]))),
201
+ marital_status=escape_latex_special_chars(patient.get("marital_status", "") or ""),
202
+ language=escape_latex_special_chars(patient.get("language", "") or ""),
203
+ notes=notes_content,
204
+ conditions=conditions_content,
205
+ medications=medications_content,
206
+ encounters=encounters_content
207
+ )
208
+
209
+ # Compile LaTeX in a temporary directory
210
+ with TemporaryDirectory() as tmpdir:
211
+ tex_path = os.path.join(tmpdir, "report.tex")
212
+ pdf_path = os.path.join(tmpdir, "report.pdf")
213
+
214
+ with open(tex_path, "w", encoding="utf-8") as f:
215
+ f.write(latex_filled)
216
+
217
+ try:
218
+ # Run latexmk twice to ensure proper table rendering
219
+ for _ in range(2):
220
+ result = subprocess.run(
221
+ ["latexmk", "-pdf", "-interaction=nonstopmode", tex_path],
222
+ cwd=tmpdir,
223
+ check=False,
224
+ capture_output=True,
225
+ text=True
226
+ )
227
+
228
+ if result.returncode != 0:
229
+ raise HTTPException(
230
+ status_code=500,
231
+ detail=f"LaTeX compilation failed: stdout={result.stdout}, stderr={result.stderr}"
232
+ )
233
+
234
+ except subprocess.CalledProcessError as e:
235
+ raise HTTPException(
236
+ status_code=500,
237
+ detail=f"LaTeX compilation failed: stdout={e.stdout}, stderr={e.stderr}"
238
+ )
239
+
240
+ if not os.path.exists(pdf_path):
241
+ raise HTTPException(
242
+ status_code=500,
243
+ detail="PDF file was not generated"
244
+ )
245
+
246
+ with open(pdf_path, "rb") as f:
247
+ pdf_bytes = f.read()
248
+
249
+ response = Response(
250
+ content=pdf_bytes,
251
+ media_type="application/pdf",
252
+ headers={"Content-Disposition": f"attachment; filename=patient_{patient.get('fhir_id', 'unknown')}_report.pdf"}
253
+ )
254
+ return response
255
+
256
+ except HTTPException as http_error:
257
+ raise http_error
258
+ except Exception as e:
259
+ raise HTTPException(
260
+ status_code=500,
261
+ detail=f"Unexpected error generating PDF: {str(e)}"
262
+ )
263
+ finally:
264
+ # Restore the logger level for other routes
265
+ logger.setLevel(logging.INFO)
266
+
267
+ # Export the router as 'pdf' for api.__init__.py
268
+ pdf = router
api/routes/txagent.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
2
+ from fastapi.responses import StreamingResponse
3
+ from typing import Optional, List
4
+ from pydantic import BaseModel
5
+ from core.security import get_current_user
6
+ from api.services.txagent_service import txagent_service
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter()
12
+
13
+ class ChatRequest(BaseModel):
14
+ message: str
15
+ history: Optional[List[dict]] = None
16
+ patient_id: Optional[str] = None
17
+
18
+ class VoiceOutputRequest(BaseModel):
19
+ text: str
20
+ language: str = "en-US"
21
+
22
+ @router.get("/txagent/status")
23
+ async def get_txagent_status(current_user: dict = Depends(get_current_user)):
24
+ """Obtient le statut du service TxAgent"""
25
+ try:
26
+ status = await txagent_service.get_status()
27
+ return {
28
+ "status": "success",
29
+ "txagent_status": status,
30
+ "mode": txagent_service.config.get_txagent_mode()
31
+ }
32
+ except Exception as e:
33
+ logger.error(f"Error getting TxAgent status: {e}")
34
+ raise HTTPException(status_code=500, detail="Failed to get TxAgent status")
35
+
36
+ @router.post("/txagent/chat")
37
+ async def chat_with_txagent(
38
+ request: ChatRequest,
39
+ current_user: dict = Depends(get_current_user)
40
+ ):
41
+ """Chat avec TxAgent"""
42
+ try:
43
+ # Vérifier que l'utilisateur est médecin ou admin
44
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
45
+ raise HTTPException(status_code=403, detail="Only doctors and admins can use TxAgent")
46
+
47
+ response = await txagent_service.chat(
48
+ message=request.message,
49
+ history=request.history,
50
+ patient_id=request.patient_id
51
+ )
52
+
53
+ return {
54
+ "status": "success",
55
+ "response": response,
56
+ "mode": txagent_service.config.get_txagent_mode()
57
+ }
58
+ except Exception as e:
59
+ logger.error(f"Error in TxAgent chat: {e}")
60
+ raise HTTPException(status_code=500, detail="Failed to process chat request")
61
+
62
+ @router.post("/txagent/voice/transcribe")
63
+ async def transcribe_audio(
64
+ audio: UploadFile = File(...),
65
+ current_user: dict = Depends(get_current_user)
66
+ ):
67
+ """Transcription vocale avec TxAgent"""
68
+ try:
69
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
70
+ raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
71
+
72
+ audio_data = await audio.read()
73
+ result = await txagent_service.voice_transcribe(audio_data)
74
+
75
+ return {
76
+ "status": "success",
77
+ "transcription": result,
78
+ "mode": txagent_service.config.get_txagent_mode()
79
+ }
80
+ except Exception as e:
81
+ logger.error(f"Error in voice transcription: {e}")
82
+ raise HTTPException(status_code=500, detail="Failed to transcribe audio")
83
+
84
+ @router.post("/txagent/voice/synthesize")
85
+ async def synthesize_speech(
86
+ request: VoiceOutputRequest,
87
+ current_user: dict = Depends(get_current_user)
88
+ ):
89
+ """Synthèse vocale avec TxAgent"""
90
+ try:
91
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
92
+ raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
93
+
94
+ audio_data = await txagent_service.voice_synthesize(
95
+ text=request.text,
96
+ language=request.language
97
+ )
98
+
99
+ return StreamingResponse(
100
+ iter([audio_data]),
101
+ media_type="audio/mpeg",
102
+ headers={
103
+ "Content-Disposition": "attachment; filename=synthesized_speech.mp3"
104
+ }
105
+ )
106
+ except Exception as e:
107
+ logger.error(f"Error in voice synthesis: {e}")
108
+ raise HTTPException(status_code=500, detail="Failed to synthesize speech")
109
+
110
+ @router.post("/txagent/patients/analyze")
111
+ async def analyze_patient_data(
112
+ patient_data: dict,
113
+ current_user: dict = Depends(get_current_user)
114
+ ):
115
+ """Analyse de données patient avec TxAgent"""
116
+ try:
117
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
118
+ raise HTTPException(status_code=403, detail="Only doctors and admins can use analysis features")
119
+
120
+ analysis = await txagent_service.analyze_patient(patient_data)
121
+
122
+ return {
123
+ "status": "success",
124
+ "analysis": analysis,
125
+ "mode": txagent_service.config.get_txagent_mode()
126
+ }
127
+ except Exception as e:
128
+ logger.error(f"Error in patient analysis: {e}")
129
+ raise HTTPException(status_code=500, detail="Failed to analyze patient data")
130
+
131
+ @router.get("/txagent/chats")
132
+ async def get_chats(current_user: dict = Depends(get_current_user)):
133
+ """Obtient l'historique des chats"""
134
+ try:
135
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
136
+ raise HTTPException(status_code=403, detail="Only doctors and admins can access chats")
137
+
138
+ # Cette fonction devra être implémentée dans le service TxAgent
139
+ chats = await txagent_service.get_chats()
140
+
141
+ return {
142
+ "status": "success",
143
+ "chats": chats,
144
+ "mode": txagent_service.config.get_txagent_mode()
145
+ }
146
+ except Exception as e:
147
+ logger.error(f"Error getting chats: {e}")
148
+ raise HTTPException(status_code=500, detail="Failed to get chats")
149
+
150
+ @router.get("/txagent/patients/analysis-results")
151
+ async def get_analysis_results(
152
+ risk_filter: Optional[str] = None,
153
+ current_user: dict = Depends(get_current_user)
154
+ ):
155
+ """Obtient les résultats d'analyse des patients"""
156
+ try:
157
+ if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
158
+ raise HTTPException(status_code=403, detail="Only doctors and admins can access analysis results")
159
+
160
+ # Cette fonction devra être implémentée dans le service TxAgent
161
+ results = await txagent_service.get_analysis_results(risk_filter)
162
+
163
+ return {
164
+ "status": "success",
165
+ "results": results,
166
+ "mode": txagent_service.config.get_txagent_mode()
167
+ }
168
+ except Exception as e:
169
+ logger.error(f"Error getting analysis results: {e}")
170
+ raise HTTPException(status_code=500, detail="Failed to get analysis results")
api/services/fhir_integration.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Dict, Optional
3
+ from api.utils.fhir_client import HAPIFHIRClient
4
+ from db.mongo import db
5
+
6
+ class HAPIFHIRIntegrationService:
7
+ """
8
+ Service to integrate HAPI FHIR data with your existing database
9
+ """
10
+
11
+ def __init__(self):
12
+ self.fhir_client = HAPIFHIRClient()
13
+
14
+ async def import_patients_from_hapi(self, limit: int = 20) -> dict:
15
+ """
16
+ Import patients from HAPI FHIR Test Server with detailed feedback
17
+ """
18
+ try:
19
+ print(f"Fetching {limit} patients from HAPI FHIR...")
20
+ patients = self.fhir_client.get_patients(limit=limit)
21
+
22
+ if not patients:
23
+ print("No patients found in HAPI FHIR")
24
+ return {
25
+ "imported_count": 0,
26
+ "skipped_count": 0,
27
+ "total_found": 0,
28
+ "imported_patients": [],
29
+ "skipped_patients": [],
30
+ "errors": []
31
+ }
32
+
33
+ print(f"Found {len(patients)} patients, checking for duplicates...")
34
+
35
+ imported_count = 0
36
+ skipped_count = 0
37
+ imported_patients = []
38
+ skipped_patients = []
39
+ errors = []
40
+
41
+ for patient in patients:
42
+ try:
43
+ # Check if patient already exists by multiple criteria
44
+ existing = await db.patients.find_one({
45
+ "$or": [
46
+ {"fhir_id": patient['fhir_id']},
47
+ {"full_name": patient['full_name'], "date_of_birth": patient['date_of_birth']},
48
+ {"demographics.fhir_id": patient['fhir_id']}
49
+ ]
50
+ })
51
+
52
+ if existing:
53
+ skipped_count += 1
54
+ skipped_patients.append(patient['full_name'])
55
+ print(f"Patient {patient['full_name']} already exists (fhir_id: {patient['fhir_id']}), skipping...")
56
+ continue
57
+
58
+ # Enhance patient data with additional FHIR data
59
+ enhanced_patient = await self._enhance_patient_data(patient)
60
+
61
+ # Insert into database
62
+ result = await db.patients.insert_one(enhanced_patient)
63
+
64
+ if result.inserted_id:
65
+ imported_count += 1
66
+ imported_patients.append(patient['full_name'])
67
+ print(f"Imported patient: {patient['full_name']} (ID: {result.inserted_id})")
68
+
69
+ except Exception as e:
70
+ error_msg = f"Error importing patient {patient.get('full_name', 'Unknown')}: {e}"
71
+ errors.append(error_msg)
72
+ print(error_msg)
73
+ continue
74
+
75
+ print(f"Import completed: {imported_count} imported, {skipped_count} skipped")
76
+
77
+ return {
78
+ "imported_count": imported_count,
79
+ "skipped_count": skipped_count,
80
+ "total_found": len(patients),
81
+ "imported_patients": imported_patients,
82
+ "skipped_patients": skipped_patients,
83
+ "errors": errors
84
+ }
85
+
86
+ except Exception as e:
87
+ print(f"Error importing patients: {e}")
88
+ return {
89
+ "imported_count": 0,
90
+ "skipped_count": 0,
91
+ "total_found": 0,
92
+ "imported_patients": [],
93
+ "skipped_patients": [],
94
+ "errors": [str(e)]
95
+ }
96
+
97
+ async def _enhance_patient_data(self, patient: Dict) -> Dict:
98
+ """
99
+ Enhance patient data with additional FHIR resources
100
+ """
101
+ try:
102
+ patient_id = patient['fhir_id']
103
+
104
+ # Fetch additional data from HAPI FHIR
105
+ observations = self.fhir_client.get_patient_observations(patient_id)
106
+ medications = self.fhir_client.get_patient_medications(patient_id)
107
+ conditions = self.fhir_client.get_patient_conditions(patient_id)
108
+
109
+ # Structure the enhanced patient data
110
+ enhanced_patient = {
111
+ # Basic demographics
112
+ **patient,
113
+
114
+ # Clinical data
115
+ 'demographics': {
116
+ 'id': patient['id'],
117
+ 'fhir_id': patient['fhir_id'],
118
+ 'full_name': patient['full_name'],
119
+ 'gender': patient['gender'],
120
+ 'date_of_birth': patient['date_of_birth'],
121
+ 'address': patient['address'],
122
+ 'phone': patient.get('phone', ''),
123
+ 'email': patient.get('email', ''),
124
+ 'marital_status': patient.get('marital_status', 'Unknown'),
125
+ 'language': patient.get('language', 'English')
126
+ },
127
+
128
+ 'clinical_data': {
129
+ 'observations': observations,
130
+ 'medications': medications,
131
+ 'conditions': conditions,
132
+ 'notes': [], # Will be populated separately
133
+ 'encounters': [] # Will be populated separately
134
+ },
135
+
136
+ 'metadata': {
137
+ 'source': 'hapi_fhir',
138
+ 'import_date': datetime.now().isoformat(),
139
+ 'last_updated': datetime.now().isoformat(),
140
+ 'fhir_server': 'https://hapi.fhir.org/baseR4'
141
+ }
142
+ }
143
+
144
+ return enhanced_patient
145
+
146
+ except Exception as e:
147
+ print(f"Error enhancing patient data: {e}")
148
+ return patient
149
+
150
+ async def sync_patient_data(self, patient_id: str) -> bool:
151
+ """
152
+ Sync a specific patient's data from HAPI FHIR
153
+ """
154
+ try:
155
+ # Get patient from HAPI FHIR
156
+ patient = self.fhir_client.get_patient_by_id(patient_id)
157
+
158
+ if not patient:
159
+ print(f"Patient {patient_id} not found in HAPI FHIR")
160
+ return False
161
+
162
+ # Enhance with additional data
163
+ enhanced_patient = await self._enhance_patient_data(patient)
164
+
165
+ # Update in database
166
+ result = await db.patients.update_one(
167
+ {"fhir_id": patient_id},
168
+ {"$set": enhanced_patient},
169
+ upsert=True
170
+ )
171
+
172
+ if result.modified_count > 0 or result.upserted_id:
173
+ print(f"Synced patient: {patient['full_name']}")
174
+ return True
175
+ else:
176
+ print(f"No changes for patient: {patient['full_name']}")
177
+ return False
178
+
179
+ except Exception as e:
180
+ print(f"Error syncing patient {patient_id}: {e}")
181
+ return False
182
+
183
+ async def get_patient_statistics(self) -> Dict:
184
+ """
185
+ Get statistics about imported patients
186
+ """
187
+ try:
188
+ total_patients = await db.patients.count_documents({})
189
+ hapi_patients = await db.patients.count_documents({"source": "hapi_fhir"})
190
+
191
+ # Get sample patient data and convert ObjectId to string
192
+ sample_patient = await db.patients.find_one({"source": "hapi_fhir"})
193
+ if sample_patient:
194
+ # Convert ObjectId to string for JSON serialization
195
+ sample_patient['_id'] = str(sample_patient['_id'])
196
+
197
+ stats = {
198
+ 'total_patients': total_patients,
199
+ 'hapi_fhir_patients': hapi_patients,
200
+ 'sample_patient': sample_patient
201
+ }
202
+
203
+ return stats
204
+
205
+ except Exception as e:
206
+ print(f"Error getting statistics: {e}")
207
+ return {}
208
+
209
+ async def get_hapi_patients(self, limit: int = 50) -> List[Dict]:
210
+ """
211
+ Get patients from HAPI FHIR without importing them
212
+ """
213
+ try:
214
+ patients = self.fhir_client.get_patients(limit=limit)
215
+ return patients
216
+ except Exception as e:
217
+ print(f"Error fetching HAPI patients: {e}")
218
+ return []
219
+
220
+ async def get_hapi_patient_details(self, patient_id: str) -> Optional[Dict]:
221
+ """
222
+ Get detailed information for a specific HAPI FHIR patient
223
+ """
224
+ try:
225
+ patient = self.fhir_client.get_patient_by_id(patient_id)
226
+ if not patient:
227
+ return None
228
+
229
+ # Get additional data
230
+ observations = self.fhir_client.get_patient_observations(patient_id)
231
+ medications = self.fhir_client.get_patient_medications(patient_id)
232
+ conditions = self.fhir_client.get_patient_conditions(patient_id)
233
+
234
+ return {
235
+ 'patient': patient,
236
+ 'observations': observations,
237
+ 'medications': medications,
238
+ 'conditions': conditions
239
+ }
240
+
241
+ except Exception as e:
242
+ print(f"Error fetching patient details: {e}")
243
+ return None
api/services/txagent_service.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiohttp
2
+ import asyncio
3
+ import logging
4
+ from typing import Optional, Dict, Any, List
5
+ from core.txagent_config import txagent_config
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class TxAgentService:
10
+ def __init__(self):
11
+ self.config = txagent_config
12
+ self.session = None
13
+
14
+ async def _get_session(self):
15
+ """Obtient ou crée une session HTTP"""
16
+ if self.session is None:
17
+ self.session = aiohttp.ClientSession()
18
+ return self.session
19
+
20
+ async def _make_request(self, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Dict[str, Any]:
21
+ """Fait une requête vers le service TxAgent avec fallback"""
22
+ session = await self._get_session()
23
+ url = f"{self.config.get_txagent_url()}{endpoint}"
24
+
25
+ try:
26
+ if method.upper() == "GET":
27
+ async with session.get(url) as response:
28
+ return await response.json()
29
+ elif method.upper() == "POST":
30
+ async with session.post(url, json=data) as response:
31
+ return await response.json()
32
+ except Exception as e:
33
+ logger.error(f"Error calling TxAgent service: {e}")
34
+ # Fallback vers cloud si local échoue
35
+ if self.config.get_txagent_mode() == "local":
36
+ logger.info("Falling back to cloud TxAgent service")
37
+ self.config.mode = "cloud"
38
+ return await self._make_request(endpoint, method, data)
39
+ else:
40
+ raise
41
+
42
+ async def chat(self, message: str, history: Optional[list] = None, patient_id: Optional[str] = None) -> Dict[str, Any]:
43
+ """Service de chat avec TxAgent"""
44
+ data = {
45
+ "message": message,
46
+ "history": history or [],
47
+ "patient_id": patient_id
48
+ }
49
+ return await self._make_request("/chat", "POST", data)
50
+
51
+ async def analyze_patient(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
52
+ """Analyse de données patient avec TxAgent"""
53
+ return await self._make_request("/patients/analyze", "POST", patient_data)
54
+
55
+ async def voice_transcribe(self, audio_data: bytes) -> Dict[str, Any]:
56
+ """Transcription vocale avec TxAgent"""
57
+ session = await self._get_session()
58
+ url = f"{self.config.get_txagent_url()}/voice/transcribe"
59
+
60
+ try:
61
+ form_data = aiohttp.FormData()
62
+ form_data.add_field('audio', audio_data, filename='audio.wav')
63
+
64
+ async with session.post(url, data=form_data) as response:
65
+ return await response.json()
66
+ except Exception as e:
67
+ logger.error(f"Error in voice transcription: {e}")
68
+ if self.config.get_txagent_mode() == "local":
69
+ self.config.mode = "cloud"
70
+ return await self.voice_transcribe(audio_data)
71
+ else:
72
+ raise
73
+
74
+ async def voice_synthesize(self, text: str, language: str = "en-US") -> bytes:
75
+ """Synthèse vocale avec TxAgent"""
76
+ session = await self._get_session()
77
+ url = f"{self.config.get_txagent_url()}/voice/synthesize"
78
+
79
+ try:
80
+ data = {
81
+ "text": text,
82
+ "language": language,
83
+ "return_format": "mp3"
84
+ }
85
+
86
+ async with session.post(url, json=data) as response:
87
+ return await response.read()
88
+ except Exception as e:
89
+ logger.error(f"Error in voice synthesis: {e}")
90
+ if self.config.get_txagent_mode() == "local":
91
+ self.config.mode = "cloud"
92
+ return await self.voice_synthesize(text, language)
93
+ else:
94
+ raise
95
+
96
+ async def get_status(self) -> Dict[str, Any]:
97
+ """Obtient le statut du service TxAgent"""
98
+ return await self._make_request("/status")
99
+
100
+ async def get_chats(self) -> List[Dict[str, Any]]:
101
+ """Obtient l'historique des chats"""
102
+ return await self._make_request("/chats")
103
+
104
+ async def get_analysis_results(self, risk_filter: Optional[str] = None) -> List[Dict[str, Any]]:
105
+ """Obtient les résultats d'analyse des patients"""
106
+ params = {}
107
+ if risk_filter:
108
+ params["risk_filter"] = risk_filter
109
+ return await self._make_request("/patients/analysis-results", "GET", params)
110
+
111
+ async def close(self):
112
+ """Ferme la session HTTP"""
113
+ if self.session:
114
+ await self.session.close()
115
+ self.session = None
116
+
117
+ # Instance globale
118
+ txagent_service = TxAgentService()
api/utils/fhir_client.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+ from typing import List, Dict, Optional
4
+ from datetime import datetime
5
+
6
+ class HAPIFHIRClient:
7
+ """
8
+ Client for connecting to HAPI FHIR Test Server
9
+ """
10
+
11
+ def __init__(self, base_url: str = "https://hapi.fhir.org/baseR4"):
12
+ self.base_url = base_url
13
+ self.session = requests.Session()
14
+ self.session.headers.update({
15
+ 'Content-Type': 'application/fhir+json',
16
+ 'Accept': 'application/fhir+json'
17
+ })
18
+
19
+ def get_patients(self, limit: int = 50) -> List[Dict]:
20
+ """
21
+ Fetch patients from HAPI FHIR Test Server
22
+ """
23
+ try:
24
+ url = f"{self.base_url}/Patient"
25
+ params = {
26
+ '_count': limit,
27
+ '_format': 'json'
28
+ }
29
+
30
+ response = self.session.get(url, params=params)
31
+ response.raise_for_status()
32
+
33
+ data = response.json()
34
+ patients = []
35
+
36
+ if 'entry' in data:
37
+ for entry in data['entry']:
38
+ patient = self._parse_patient(entry['resource'])
39
+ if patient:
40
+ patients.append(patient)
41
+
42
+ return patients
43
+
44
+ except requests.RequestException as e:
45
+ print(f"Error fetching patients: {e}")
46
+ return []
47
+
48
+ def get_patient_by_id(self, patient_id: str) -> Optional[Dict]:
49
+ """
50
+ Fetch a specific patient by ID
51
+ """
52
+ try:
53
+ url = f"{self.base_url}/Patient/{patient_id}"
54
+ response = self.session.get(url)
55
+ response.raise_for_status()
56
+
57
+ patient_data = response.json()
58
+ return self._parse_patient(patient_data)
59
+
60
+ except requests.RequestException as e:
61
+ print(f"Error fetching patient {patient_id}: {e}")
62
+ return None
63
+
64
+ def get_patient_observations(self, patient_id: str) -> List[Dict]:
65
+ """
66
+ Fetch observations (vital signs, lab results) for a patient
67
+ """
68
+ try:
69
+ url = f"{self.base_url}/Observation"
70
+ params = {
71
+ 'subject': f"Patient/{patient_id}",
72
+ '_count': 100,
73
+ '_format': 'json'
74
+ }
75
+
76
+ response = self.session.get(url, params=params)
77
+ response.raise_for_status()
78
+
79
+ data = response.json()
80
+ observations = []
81
+
82
+ if 'entry' in data:
83
+ for entry in data['entry']:
84
+ observation = self._parse_observation(entry['resource'])
85
+ if observation:
86
+ observations.append(observation)
87
+
88
+ return observations
89
+
90
+ except requests.RequestException as e:
91
+ print(f"Error fetching observations for patient {patient_id}: {e}")
92
+ return []
93
+
94
+ def get_patient_medications(self, patient_id: str) -> List[Dict]:
95
+ """
96
+ Fetch medications for a patient
97
+ """
98
+ try:
99
+ url = f"{self.base_url}/MedicationRequest"
100
+ params = {
101
+ 'subject': f"Patient/{patient_id}",
102
+ '_count': 100,
103
+ '_format': 'json'
104
+ }
105
+
106
+ response = self.session.get(url, params=params)
107
+ response.raise_for_status()
108
+
109
+ data = response.json()
110
+ medications = []
111
+
112
+ if 'entry' in data:
113
+ for entry in data['entry']:
114
+ medication = self._parse_medication(entry['resource'])
115
+ if medication:
116
+ medications.append(medication)
117
+
118
+ return medications
119
+
120
+ except requests.RequestException as e:
121
+ print(f"Error fetching medications for patient {patient_id}: {e}")
122
+ return []
123
+
124
+ def get_patient_conditions(self, patient_id: str) -> List[Dict]:
125
+ """
126
+ Fetch conditions (diagnoses) for a patient
127
+ """
128
+ try:
129
+ url = f"{self.base_url}/Condition"
130
+ params = {
131
+ 'subject': f"Patient/{patient_id}",
132
+ '_count': 100,
133
+ '_format': 'json'
134
+ }
135
+
136
+ response = self.session.get(url, params=params)
137
+ response.raise_for_status()
138
+
139
+ data = response.json()
140
+ conditions = []
141
+
142
+ if 'entry' in data:
143
+ for entry in data['entry']:
144
+ condition = self._parse_condition(entry['resource'])
145
+ if condition:
146
+ conditions.append(condition)
147
+
148
+ return conditions
149
+
150
+ except requests.RequestException as e:
151
+ print(f"Error fetching conditions for patient {patient_id}: {e}")
152
+ return []
153
+
154
+ def _parse_patient(self, patient_data: Dict) -> Optional[Dict]:
155
+ """
156
+ Parse FHIR Patient resource into our format
157
+ """
158
+ try:
159
+ # Extract basic demographics
160
+ name = ""
161
+ if 'name' in patient_data and patient_data['name']:
162
+ name_parts = patient_data['name'][0]
163
+ given = name_parts.get('given', [])
164
+ family = name_parts.get('family', '')
165
+ name = f"{' '.join(given)} {family}".strip()
166
+
167
+ # Extract address
168
+ address = ""
169
+ if 'address' in patient_data and patient_data['address']:
170
+ addr = patient_data['address'][0]
171
+ line = addr.get('line', [])
172
+ city = addr.get('city', '')
173
+ state = addr.get('state', '')
174
+ postal_code = addr.get('postalCode', '')
175
+ address = f"{', '.join(line)}, {city}, {state} {postal_code}".strip()
176
+
177
+ # Extract contact info
178
+ phone = ""
179
+ email = ""
180
+ if 'telecom' in patient_data:
181
+ for telecom in patient_data['telecom']:
182
+ if telecom.get('system') == 'phone':
183
+ phone = telecom.get('value', '')
184
+ elif telecom.get('system') == 'email':
185
+ email = telecom.get('value', '')
186
+
187
+ return {
188
+ 'id': patient_data.get('id', ''),
189
+ 'fhir_id': patient_data.get('id', ''),
190
+ 'full_name': name,
191
+ 'gender': patient_data.get('gender', 'unknown'),
192
+ 'date_of_birth': patient_data.get('birthDate', ''),
193
+ 'address': address,
194
+ 'phone': phone,
195
+ 'email': email,
196
+ 'marital_status': self._get_marital_status(patient_data),
197
+ 'language': self._get_language(patient_data),
198
+ 'source': 'hapi_fhir',
199
+ 'status': 'active',
200
+ 'created_at': datetime.now().isoformat(),
201
+ 'updated_at': datetime.now().isoformat()
202
+ }
203
+
204
+ except Exception as e:
205
+ print(f"Error parsing patient data: {e}")
206
+ return None
207
+
208
+ def _parse_observation(self, observation_data: Dict) -> Optional[Dict]:
209
+ """
210
+ Parse FHIR Observation resource
211
+ """
212
+ try:
213
+ code = observation_data.get('code', {})
214
+ coding = code.get('coding', [])
215
+ code_text = code.get('text', '')
216
+
217
+ if coding:
218
+ code_text = coding[0].get('display', code_text)
219
+
220
+ value = observation_data.get('valueQuantity', {})
221
+ unit = value.get('unit', '')
222
+ value_amount = value.get('value', '')
223
+
224
+ return {
225
+ 'id': observation_data.get('id', ''),
226
+ 'code': code_text,
227
+ 'value': f"{value_amount} {unit}".strip(),
228
+ 'date': observation_data.get('effectiveDateTime', ''),
229
+ 'category': self._get_observation_category(observation_data)
230
+ }
231
+
232
+ except Exception as e:
233
+ print(f"Error parsing observation: {e}")
234
+ return None
235
+
236
+ def _parse_medication(self, medication_data: Dict) -> Optional[Dict]:
237
+ """
238
+ Parse FHIR MedicationRequest resource
239
+ """
240
+ try:
241
+ medication = medication_data.get('medicationCodeableConcept', {})
242
+ coding = medication.get('coding', [])
243
+ name = medication.get('text', '')
244
+
245
+ if coding:
246
+ name = coding[0].get('display', name)
247
+
248
+ dosage = medication_data.get('dosageInstruction', [])
249
+ dosage_text = ""
250
+ if dosage:
251
+ dosage_text = dosage[0].get('text', '')
252
+
253
+ return {
254
+ 'id': medication_data.get('id', ''),
255
+ 'name': name,
256
+ 'dosage': dosage_text,
257
+ 'status': medication_data.get('status', 'active'),
258
+ 'prescribed_date': medication_data.get('authoredOn', ''),
259
+ 'requester': self._get_practitioner_name(medication_data)
260
+ }
261
+
262
+ except Exception as e:
263
+ print(f"Error parsing medication: {e}")
264
+ return None
265
+
266
+ def _parse_condition(self, condition_data: Dict) -> Optional[Dict]:
267
+ """
268
+ Parse FHIR Condition resource
269
+ """
270
+ try:
271
+ code = condition_data.get('code', {})
272
+ coding = code.get('coding', [])
273
+ name = code.get('text', '')
274
+
275
+ if coding:
276
+ name = coding[0].get('display', name)
277
+
278
+ return {
279
+ 'id': condition_data.get('id', ''),
280
+ 'code': name,
281
+ 'status': condition_data.get('clinicalStatus', {}).get('coding', [{}])[0].get('code', 'active'),
282
+ 'onset_date': condition_data.get('onsetDateTime', ''),
283
+ 'recorded_date': condition_data.get('recordedDate', ''),
284
+ 'notes': condition_data.get('note', [{}])[0].get('text', '') if condition_data.get('note') else ''
285
+ }
286
+
287
+ except Exception as e:
288
+ print(f"Error parsing condition: {e}")
289
+ return None
290
+
291
+ def _get_marital_status(self, patient_data: Dict) -> str:
292
+ """Extract marital status from patient data"""
293
+ if 'maritalStatus' in patient_data:
294
+ coding = patient_data['maritalStatus'].get('coding', [])
295
+ if coding:
296
+ return coding[0].get('display', 'Unknown')
297
+ return 'Unknown'
298
+
299
+ def _get_language(self, patient_data: Dict) -> str:
300
+ """Extract language from patient data"""
301
+ if 'communication' in patient_data and patient_data['communication']:
302
+ language = patient_data['communication'][0].get('language', {})
303
+ coding = language.get('coding', [])
304
+ if coding:
305
+ return coding[0].get('display', 'English')
306
+ return 'English'
307
+
308
+ def _get_observation_category(self, observation_data: Dict) -> str:
309
+ """Extract observation category"""
310
+ category = observation_data.get('category', {})
311
+ coding = category.get('coding', [])
312
+ if coding:
313
+ return coding[0].get('display', 'Unknown')
314
+ return 'Unknown'
315
+
316
+ def _get_practitioner_name(self, medication_data: Dict) -> str:
317
+ """Extract practitioner name from medication request"""
318
+ requester = medication_data.get('requester', {})
319
+ reference = requester.get('reference', '')
320
+ if reference.startswith('Practitioner/'):
321
+ # In a real implementation, you'd fetch the practitioner details
322
+ return 'Dr. Practitioner'
323
+ return 'Unknown'
app.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException, Response
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import RedirectResponse, HTMLResponse
4
+ from api import api_router
5
+ import gradio as gr
6
+ import requests
7
+ import logging
8
+ import time
9
+ import aiohttp
10
+ import asyncio
11
+ from typing import Optional
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.DEBUG)
15
+ logger = logging.getLogger(__name__)
16
+ logger.debug("Initializing application")
17
+
18
+ app = FastAPI()
19
+
20
+ # CORS Configuration
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=True,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ app.include_router(api_router)
30
+
31
+ # Constants
32
+ BACKEND_URL = "https://rocketfarmstudios-cps-api.hf.space"
33
+ ADMIN_EMAIL = "yakdhanali97@gmail.com"
34
+ ADMIN_PASSWORD = "123456"
35
+ MAX_TOKEN_RETRIES = 3
36
+ TOKEN_RETRY_DELAY = 2 # seconds
37
+
38
+ class TokenManager:
39
+ def __init__(self):
40
+ self.token = None
41
+ self.last_refresh = 0
42
+ self.expires_in = 3600 # 1 hour default expiry
43
+ self.lock = asyncio.Lock()
44
+
45
+ async def _make_login_request(self) -> Optional[str]:
46
+ try:
47
+ async with aiohttp.ClientSession() as session:
48
+ async with session.post(
49
+ f"{BACKEND_URL}/auth/login",
50
+ json={
51
+ "username": ADMIN_EMAIL,
52
+ "password": ADMIN_PASSWORD,
53
+ "device_token": "admin-device-token"
54
+ },
55
+ timeout=10
56
+ ) as response:
57
+ if response.status == 200:
58
+ data = await response.json()
59
+ return data.get("access_token")
60
+ else:
61
+ error = await response.text()
62
+ logger.error(f"Login failed: {response.status} - {error}")
63
+ return None
64
+ except Exception as e:
65
+ logger.error(f"Login request error: {str(e)}")
66
+ return None
67
+
68
+ async def refresh_token(self) -> str:
69
+ async with self.lock:
70
+ for attempt in range(MAX_TOKEN_RETRIES):
71
+ token = await self._make_login_request()
72
+ if token:
73
+ self.token = token
74
+ self.last_refresh = time.time()
75
+ logger.info("Successfully refreshed admin token")
76
+ return token
77
+
78
+ wait_time = min(5, (attempt + 1) * 2) # Exponential backoff with max 5s
79
+ logger.warning(f"Attempt {attempt + 1} failed, retrying in {wait_time}s...")
80
+ await asyncio.sleep(wait_time)
81
+
82
+ raise Exception("Failed to obtain admin token after multiple attempts")
83
+
84
+ async def get_token(self) -> str:
85
+ if not self.token or (time.time() - self.last_refresh) > (self.expires_in - 60):
86
+ return await self.refresh_token()
87
+ return self.token
88
+
89
+ token_manager = TokenManager()
90
+
91
+ @app.get("/")
92
+ def root():
93
+ logger.debug("Root endpoint accessed")
94
+ return {"message": "🚀 FastAPI with MongoDB + JWT is running."}
95
+
96
+ @app.post("/login")
97
+ async def redirect_login(request: Request):
98
+ logger.info("Redirecting /login to /auth/login")
99
+ return RedirectResponse(url="/auth/login", status_code=307)
100
+
101
+ def authenticate_admin(email: str = None, password: str = None):
102
+ if email != ADMIN_EMAIL or password != ADMIN_PASSWORD:
103
+ logger.warning(f"Failed admin login attempt with email: {email}")
104
+ raise HTTPException(status_code=401, detail="Unauthorized: Invalid email or password")
105
+
106
+ logger.info(f"Admin authenticated successfully: {email}")
107
+ return True
108
+
109
+ async def async_create_doctor(full_name, email, matricule, password, specialty):
110
+ try:
111
+ token = await token_manager.get_token()
112
+
113
+ payload = {
114
+ "full_name": full_name,
115
+ "email": email,
116
+ "license_number": matricule,
117
+ "password": password,
118
+ "specialty": specialty,
119
+ }
120
+ headers = {
121
+ "Authorization": f"Bearer {token}",
122
+ "Content-Type": "application/json"
123
+ }
124
+
125
+ async with aiohttp.ClientSession() as session:
126
+ async with session.post(
127
+ f"{BACKEND_URL}/auth/admin/doctors",
128
+ json=payload,
129
+ headers=headers,
130
+ timeout=10
131
+ ) as response:
132
+ if response.status == 201:
133
+ return "✅ Doctor created successfully!"
134
+ elif response.status == 401: # Token might be expired
135
+ logger.warning("Token expired, attempting refresh...")
136
+ token = await token_manager.refresh_token()
137
+ headers["Authorization"] = f"Bearer {token}"
138
+ async with session.post(
139
+ f"{BACKEND_URL}/auth/admin/doctors",
140
+ json=payload,
141
+ headers=headers,
142
+ timeout=10
143
+ ) as retry_response:
144
+ if retry_response.status == 201:
145
+ return "✅ Doctor created successfully!"
146
+
147
+ error_detail = await response.text()
148
+ return f"❌ Error: {error_detail} (Status: {response.status})"
149
+
150
+ except Exception as e:
151
+ logger.error(f"Doctor creation failed: {str(e)}")
152
+ return f"❌ System Error: {str(e)}"
153
+
154
+ def sync_create_doctor(*args):
155
+ return asyncio.run(async_create_doctor(*args))
156
+
157
+ admin_ui = gr.Blocks(
158
+ css="""
159
+ .gradio-container {
160
+ font-family: Arial, sans-serif;
161
+ max-width: 800px;
162
+ margin: 0 auto;
163
+ padding: 2rem;
164
+ }
165
+ .input-group {
166
+ margin-bottom: 1.5rem;
167
+ }
168
+ input, select {
169
+ width: 100%;
170
+ padding: 0.5rem;
171
+ margin-bottom: 1rem;
172
+ }
173
+ button {
174
+ background-color: #4a6fa5;
175
+ color: white;
176
+ padding: 0.75rem;
177
+ border: none;
178
+ border-radius: 4px;
179
+ cursor: pointer;
180
+ width: 100%;
181
+ }
182
+ """
183
+ )
184
+
185
+ with admin_ui:
186
+ gr.Markdown("# Doctor Account Creator")
187
+
188
+ with gr.Column():
189
+ full_name = gr.Textbox(label="Full Name")
190
+ email = gr.Textbox(label="Email")
191
+ matricule = gr.Textbox(label="License Number")
192
+ specialty = gr.Dropdown(
193
+ label="Specialty",
194
+ choices=["General Practice", "Cardiology", "Neurology", "Pediatrics"]
195
+ )
196
+ password = gr.Textbox(label="Password", type="password")
197
+ submit_btn = gr.Button("Create Account")
198
+ output = gr.Textbox(label="Status", interactive=False)
199
+
200
+ submit_btn.click(
201
+ fn=sync_create_doctor,
202
+ inputs=[full_name, email, matricule, specialty, password],
203
+ outputs=output
204
+ )
205
+
206
+ app = gr.mount_gradio_app(app, admin_ui, path="/admin-auth")
207
+
208
+ @app.get("/admin")
209
+ async def admin_dashboard(email: str = None, password: str = None, response: Response = None):
210
+ logger.debug("Admin dashboard accessed")
211
+ try:
212
+ authenticate_admin(email, password)
213
+ return RedirectResponse(url="/admin-auth", status_code=307)
214
+ except HTTPException as e:
215
+ response.status_code = 401
216
+ return HTMLResponse(content="""
217
+ <h1>401 Unauthorized</h1>
218
+ <p>Invalid admin credentials</p>
219
+ """)
220
+
221
+ @app.on_event("startup")
222
+ async def startup_event():
223
+ """Initialize token but don't fail startup"""
224
+ try:
225
+ await token_manager.get_token()
226
+ except Exception as e:
227
+ logger.error(f"Initial token fetch failed: {str(e)}")
228
+
229
+ if __name__ == "__main__":
230
+ logger.info("Starting application")
231
+ import uvicorn
232
+ uvicorn.run(app, host="0.0.0.0", port=7860)
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This makes this directory a Python package
core/config.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv() # Load .env file
5
+
6
+ SECRET_KEY = os.getenv("SECRET_KEY")
7
+ if not SECRET_KEY:
8
+ raise RuntimeError("SECRET_KEY not set!")
9
+
10
+ ALGORITHM = "HS256"
11
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60
12
+
13
+ MONGO_URI = os.getenv("MONGO_URI")
14
+ if not MONGO_URI:
15
+ raise RuntimeError("MONGO_URI not set!")
core/security.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from passlib.context import CryptContext
3
+ from jose import jwt, JWTError
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import OAuth2PasswordBearer
6
+ from core.config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
7
+ from db.mongo import users_collection
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # OAuth2 setup
13
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
14
+
15
+ # Password hashing context
16
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
17
+
18
+ # Hash a plain password
19
+ def hash_password(password: str) -> str:
20
+ return pwd_context.hash(password)
21
+
22
+ # Verify a plain password against the hash
23
+ def verify_password(plain: str, hashed: str) -> bool:
24
+ return pwd_context.verify(plain, hashed)
25
+
26
+ # Create a JWT access token
27
+ def create_access_token(data: dict, expires_delta: timedelta = None):
28
+ to_encode = data.copy()
29
+ expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
30
+ to_encode.update({"exp": expire})
31
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
32
+
33
+ # Get the current user from the JWT token
34
+ async def get_current_user(token: str = Depends(oauth2_scheme)):
35
+ print("🔐 Token received:", token)
36
+
37
+ if not token:
38
+ print("❌ No token received")
39
+ raise HTTPException(status_code=401, detail="No token provided")
40
+
41
+ try:
42
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
43
+ print("🧠 Token payload:", payload)
44
+
45
+ email = payload.get("sub")
46
+ if not email:
47
+ raise HTTPException(status_code=401, detail="Invalid token: missing subject")
48
+ except JWTError as e:
49
+ print("❌ JWT decode error:", str(e))
50
+ raise HTTPException(status_code=401, detail="Could not validate token")
51
+
52
+ user = await users_collection.find_one({"email": email})
53
+ if not user:
54
+ raise HTTPException(status_code=404, detail="User not found")
55
+
56
+ print("✅ Authenticated user:", user["email"])
57
+ return user
core/txagent_config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Optional
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class TxAgentConfig:
8
+ def __init__(self):
9
+ self.mode = os.getenv("TXAGENT_MODE", "local") # local, cloud, hybrid
10
+ self.cloud_url = os.getenv("TXAGENT_CLOUD_URL", "https://rocketfarmstudios-txagent-api.hf.space")
11
+ self.local_enabled = os.getenv("TXAGENT_LOCAL_ENABLED", "false").lower() == "true"
12
+ self.gpu_available = self._check_gpu_availability()
13
+
14
+ def _check_gpu_availability(self) -> bool:
15
+ """Vérifie si GPU est disponible localement"""
16
+ try:
17
+ import torch
18
+ return torch.cuda.is_available()
19
+ except ImportError:
20
+ return False
21
+
22
+ def get_txagent_mode(self) -> str:
23
+ """Détermine le mode optimal pour TxAgent"""
24
+ if self.mode == "cloud":
25
+ return "cloud"
26
+ elif self.mode == "local" and self.local_enabled and self.gpu_available:
27
+ return "local"
28
+ else:
29
+ return "cloud" # Fallback vers cloud
30
+
31
+ def get_txagent_url(self) -> str:
32
+ """Retourne l'URL du service TxAgent"""
33
+ if self.get_txagent_mode() == "local":
34
+ return "http://localhost:8001" # Port local pour TxAgent
35
+ else:
36
+ return self.cloud_url
37
+
38
+ def is_local_available(self) -> bool:
39
+ """Vérifie si le mode local est disponible"""
40
+ return self.local_enabled and self.gpu_available
41
+
42
+ # Instance globale
43
+ txagent_config = TxAgentConfig()
db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This makes this directory a Python package
db/mongo.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import certifi
2
+ from motor.motor_asyncio import AsyncIOMotorClient
3
+ from core.config import MONGO_URI
4
+
5
+ # Create MongoDB client with TLS certificate
6
+ client = AsyncIOMotorClient(MONGO_URI, tls=True, tlsCAFile=certifi.where())
7
+
8
+ # Access main database
9
+ db = client["cps_db"]
10
+
11
+ # Collections
12
+ users_collection = db["users"]
13
+ patients_collection = db["patients"]
14
+ appointments_collection = db.get_collection("appointments")
15
+
16
+ # Create indexes for better duplicate detection
17
+ async def create_indexes():
18
+ """Create database indexes for better performance and duplicate detection"""
19
+ try:
20
+ # Index for EHR patients
21
+ await patients_collection.create_index([
22
+ ("ehr_id", 1),
23
+ ("ehr_system", 1)
24
+ ], unique=True, sparse=True)
25
+
26
+ # Index for HAPI FHIR patients
27
+ await patients_collection.create_index([
28
+ ("fhir_id", 1)
29
+ ], unique=True, sparse=True)
30
+
31
+ # Index for demographics.fhir_id
32
+ await patients_collection.create_index([
33
+ ("demographics.fhir_id", 1)
34
+ ], unique=True, sparse=True)
35
+
36
+ # Index for name and date of birth combination
37
+ await patients_collection.create_index([
38
+ ("full_name", 1),
39
+ ("date_of_birth", 1)
40
+ ])
41
+
42
+ # Index for national_id
43
+ await patients_collection.create_index([
44
+ ("national_id", 1)
45
+ ], unique=True, sparse=True)
46
+
47
+ # Index for source field
48
+ await patients_collection.create_index([
49
+ ("source", 1)
50
+ ])
51
+
52
+ print("Database indexes created successfully")
53
+
54
+ except Exception as e:
55
+ print(f"Error creating indexes: {e}")
56
+ # Continue without indexes if there's an error
57
+
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This makes this directory a Python package
models/entities.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+
5
+ class Condition(BaseModel):
6
+ id: str
7
+ code: str
8
+ status: Optional[str] = ""
9
+ onset_date: Optional[str] = None
10
+ recorded_date: Optional[str] = None
11
+ verification_status: Optional[str] = ""
12
+
13
+ class Medication(BaseModel):
14
+ id: str
15
+ name: str
16
+ status: str
17
+ prescribed_date: Optional[str] = None
18
+ requester: Optional[str] = ""
19
+ dosage: Optional[str] = ""
20
+
21
+ class Encounter(BaseModel):
22
+ id: str
23
+ type: str
24
+ status: str
25
+ period: dict
26
+ service_provider: Optional[str] = ""
27
+
28
+ class Note(BaseModel):
29
+ date: str
30
+ type: str
31
+ text: str
32
+ context: Optional[str] = ""
33
+ author: Optional[str] = "System"
34
+
35
+ class PatientCreate(BaseModel):
36
+ full_name: str
37
+ gender: str
38
+ date_of_birth: str
39
+ address: Optional[str] = ""
40
+ city: Optional[str] = ""
41
+ state: Optional[str] = ""
42
+ postal_code: Optional[str] = ""
43
+ country: Optional[str] = "US"
44
+ marital_status: Optional[str] = "Never Married"
45
+ language: Optional[str] = "en"
46
+ conditions: Optional[List[Condition]] = []
47
+ medications: Optional[List[Medication]] = []
48
+ encounters: Optional[List[Encounter]] = []
49
+ notes: Optional[List[Note]] = []
models/schemas.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional, List, Literal, Union, Any
3
+ from datetime import date, time, datetime
4
+ from enum import Enum
5
+
6
+ # --- USER SCHEMAS ---
7
+ class SignupForm(BaseModel):
8
+ email: EmailStr
9
+ password: str
10
+ full_name: str
11
+ # Patients can only register through signup, role is automatically set to 'patient'
12
+
13
+ class TokenResponse(BaseModel):
14
+ access_token: str
15
+ token_type: str
16
+
17
+ # --- DOCTOR CREATION SCHEMA (Admin use only) ---
18
+ class DoctorCreate(BaseModel):
19
+ license_number: str # Changed from matricule to license_number for consistency
20
+ email: EmailStr
21
+ password: str
22
+ full_name: str
23
+ specialty: str
24
+ roles: List[Literal['admin', 'doctor', 'patient']] = ['doctor'] # Can have multiple roles
25
+
26
+ # --- ADMIN CREATION SCHEMA (Admin use only) ---
27
+ class AdminCreate(BaseModel):
28
+ email: EmailStr
29
+ password: str
30
+ full_name: str
31
+ roles: List[Literal['admin', 'doctor', 'patient']] = ['admin'] # Can have multiple roles
32
+
33
+ # --- ADMIN USER MANAGEMENT ---
34
+ class AdminUserUpdate(BaseModel):
35
+ full_name: Optional[str] = None
36
+ roles: Optional[List[Literal['admin', 'doctor', 'patient']]] = None
37
+ specialty: Optional[str] = None
38
+ license_number: Optional[str] = None
39
+
40
+ class AdminPasswordReset(BaseModel):
41
+ new_password: str
42
+
43
+ # --- PROFILE UPDATE SCHEMA ---
44
+ class ProfileUpdate(BaseModel):
45
+ full_name: Optional[str] = None
46
+ phone: Optional[str] = None
47
+ address: Optional[str] = None
48
+ date_of_birth: Optional[str] = None
49
+ gender: Optional[str] = None
50
+ specialty: Optional[str] = None
51
+ license_number: Optional[str] = None
52
+
53
+ # --- PASSWORD CHANGE SCHEMA ---
54
+ class PasswordChange(BaseModel):
55
+ current_password: str
56
+ new_password: str
57
+
58
+
59
+ # --- CONTACT INFO ---
60
+ class ContactInfo(BaseModel):
61
+ email: Optional[EmailStr] = None
62
+ phone: Optional[str] = None
63
+
64
+ # --- PATIENT SOURCE TYPES ---
65
+ class PatientSource(str, Enum):
66
+ EHR = "ehr" # Patients from external EHR system
67
+ DIRECT_REGISTRATION = "direct" # Patients registered directly in the system
68
+ IMPORT = "import" # Patients imported from other systems
69
+ MANUAL = "manual" # Manually entered patients
70
+ SYNTHEA = "synthea" # Patients imported from Synthea
71
+ HAPI_FHIR = "hapi_fhir" # Patients imported from HAPI FHIR Test Server
72
+
73
+ # --- PATIENT STATUS ---
74
+ class PatientStatus(str, Enum):
75
+ ACTIVE = "active"
76
+ INACTIVE = "inactive"
77
+ ARCHIVED = "archived"
78
+ PENDING = "pending"
79
+
80
+ # --- PATIENT SCHEMA ---
81
+ class PatientCreate(BaseModel):
82
+ full_name: str
83
+ date_of_birth: date
84
+ gender: str
85
+ notes: Optional[str] = ""
86
+ address: Optional[str] = None
87
+ national_id: Optional[str] = None
88
+ blood_type: Optional[str] = None
89
+ allergies: Optional[List[str]] = []
90
+ chronic_conditions: Optional[List[str]] = []
91
+ medications: Optional[List[str]] = []
92
+ emergency_contact_name: Optional[str] = None
93
+ emergency_contact_phone: Optional[str] = None
94
+ insurance_provider: Optional[str] = None
95
+ insurance_policy_number: Optional[str] = None
96
+ contact: Optional[ContactInfo] = None
97
+ # New fields for patient source management
98
+ source: PatientSource = PatientSource.DIRECT_REGISTRATION
99
+ ehr_id: Optional[str] = None # External EHR system ID
100
+ ehr_system: Optional[str] = None # Name of the EHR system
101
+ status: PatientStatus = PatientStatus.ACTIVE
102
+ assigned_doctor_id: Optional[str] = None # Primary doctor assignment
103
+ registration_date: Optional[datetime] = None
104
+ last_visit_date: Optional[datetime] = None
105
+ next_appointment_date: Optional[datetime] = None
106
+
107
+ class PatientUpdate(BaseModel):
108
+ full_name: Optional[str] = None
109
+ date_of_birth: Optional[date] = None
110
+ gender: Optional[str] = None
111
+ notes: Optional[str] = None
112
+ address: Optional[str] = None
113
+ national_id: Optional[str] = None
114
+ blood_type: Optional[str] = None
115
+ allergies: Optional[List[str]] = None
116
+ chronic_conditions: Optional[List[str]] = None
117
+ medications: Optional[List[str]] = None
118
+ emergency_contact_name: Optional[str] = None
119
+ emergency_contact_phone: Optional[str] = None
120
+ insurance_provider: Optional[str] = None
121
+ insurance_policy_number: Optional[str] = None
122
+ contact: Optional[ContactInfo] = None
123
+ status: Optional[PatientStatus] = None
124
+ assigned_doctor_id: Optional[str] = None
125
+ last_visit_date: Optional[datetime] = None
126
+ next_appointment_date: Optional[datetime] = None
127
+
128
+ class PatientResponse(BaseModel):
129
+ id: str
130
+ full_name: str
131
+ date_of_birth: date
132
+ gender: str
133
+ notes: Optional[Union[str, List[dict]]] = [] # Can be string or list of dicts
134
+ address: Optional[str] = None
135
+ national_id: Optional[str] = None
136
+ blood_type: Optional[str] = None
137
+ allergies: List[str] = []
138
+ chronic_conditions: List[str] = []
139
+ medications: Union[List[str], List[dict]] = [] # Can be list of strings or list of dicts
140
+ emergency_contact_name: Optional[str] = None
141
+ emergency_contact_phone: Optional[str] = None
142
+ insurance_provider: Optional[str] = None
143
+ insurance_policy_number: Optional[str] = None
144
+ contact: Optional[ContactInfo] = None
145
+ source: PatientSource
146
+ ehr_id: Optional[str] = None
147
+ ehr_system: Optional[str] = None
148
+ status: PatientStatus
149
+ assigned_doctor_id: Optional[str] = None
150
+ assigned_doctor_name: Optional[str] = None
151
+ registration_date: Optional[datetime] = None
152
+ last_visit_date: Optional[datetime] = None
153
+ next_appointment_date: Optional[datetime] = None
154
+ created_at: datetime
155
+ updated_at: datetime
156
+
157
+ class PatientListResponse(BaseModel):
158
+ patients: List[PatientResponse]
159
+ total: int
160
+ page: int
161
+ limit: int
162
+ source_filter: Optional[PatientSource] = None
163
+ status_filter: Optional[PatientStatus] = None
164
+
165
+ # --- APPOINTMENT STATUS ENUM ---
166
+ class AppointmentStatus(str, Enum):
167
+ PENDING = "pending"
168
+ CONFIRMED = "confirmed"
169
+ CANCELLED = "cancelled"
170
+ COMPLETED = "completed"
171
+ NO_SHOW = "no_show"
172
+
173
+ # --- APPOINTMENT TYPE ENUM ---
174
+ class AppointmentType(str, Enum):
175
+ CHECKUP = "checkup"
176
+ CONSULTATION = "consultation"
177
+ PROCEDURE = "procedure"
178
+ FOLLOW_UP = "follow_up"
179
+ EMERGENCY = "emergency"
180
+ ROUTINE = "routine"
181
+
182
+ # --- APPOINTMENT SCHEMAS ---
183
+ class AppointmentCreate(BaseModel):
184
+ patient_id: str # MongoDB ObjectId as string
185
+ doctor_id: str # MongoDB ObjectId as string
186
+ date: str # Date as string in 'YYYY-MM-DD' format
187
+ time: str # Time as string in 'HH:MM:SS' format
188
+ type: AppointmentType = AppointmentType.CONSULTATION
189
+ reason: Optional[str] = None
190
+ notes: Optional[str] = None
191
+ duration: Optional[int] = 30 # Duration in minutes
192
+
193
+ class AppointmentUpdate(BaseModel):
194
+ date: Optional[str] = None # Date as string in 'YYYY-MM-DD' format
195
+ time: Optional[str] = None # Time as string in 'HH:MM:SS' format
196
+ type: Optional[AppointmentType] = None
197
+ reason: Optional[str] = None
198
+ notes: Optional[str] = None
199
+ status: Optional[AppointmentStatus] = None
200
+ duration: Optional[int] = None
201
+
202
+ class AppointmentResponse(BaseModel):
203
+ id: str
204
+ patient_id: str
205
+ doctor_id: str
206
+ patient_name: str
207
+ doctor_name: str
208
+ date: date
209
+ time: time
210
+ type: AppointmentType
211
+ status: AppointmentStatus
212
+ reason: Optional[str] = None
213
+ notes: Optional[str] = None
214
+ duration: int
215
+ created_at: datetime
216
+ updated_at: datetime
217
+
218
+ class AppointmentListResponse(BaseModel):
219
+ appointments: List[AppointmentResponse]
220
+ total: int
221
+ page: int
222
+ limit: int
223
+
224
+ # --- DOCTOR AVAILABILITY SCHEMAS ---
225
+ class DoctorAvailabilityCreate(BaseModel):
226
+ doctor_id: str
227
+ day_of_week: int # 0=Monday, 6=Sunday
228
+ start_time: time
229
+ end_time: time
230
+ is_available: bool = True
231
+
232
+ class DoctorAvailabilityUpdate(BaseModel):
233
+ start_time: Optional[time] = None
234
+ end_time: Optional[time] = None
235
+ is_available: Optional[bool] = None
236
+
237
+ class DoctorAvailabilityResponse(BaseModel):
238
+ id: str
239
+ doctor_id: str
240
+ doctor_name: str
241
+ day_of_week: int
242
+ start_time: time
243
+ end_time: time
244
+ is_available: bool
245
+
246
+ # --- APPOINTMENT SLOT SCHEMAS ---
247
+ class AppointmentSlot(BaseModel):
248
+ date: date
249
+ time: time
250
+ is_available: bool
251
+ appointment_id: Optional[str] = None
252
+
253
+ class AvailableSlotsResponse(BaseModel):
254
+ doctor_id: str
255
+ doctor_name: str
256
+ specialty: str
257
+ date: date
258
+ slots: List[AppointmentSlot]
259
+
260
+ # --- DOCTOR LIST RESPONSE ---
261
+ class DoctorListResponse(BaseModel):
262
+ id: str
263
+ full_name: str
264
+ specialty: str
265
+ license_number: str
266
+ email: str
267
+ phone: Optional[str] = None
268
+
269
+ # --- MESSAGING SCHEMAS ---
270
+ class MessageType(str, Enum):
271
+ TEXT = "text"
272
+ IMAGE = "image"
273
+ FILE = "file"
274
+ SYSTEM = "system"
275
+
276
+ class MessageStatus(str, Enum):
277
+ SENT = "sent"
278
+ DELIVERED = "delivered"
279
+ READ = "read"
280
+ FAILED = "failed"
281
+
282
+ class MessageCreate(BaseModel):
283
+ recipient_id: str # MongoDB ObjectId as string
284
+ content: str
285
+ message_type: MessageType = MessageType.TEXT
286
+ attachment_url: Optional[str] = None
287
+ reply_to_message_id: Optional[str] = None
288
+
289
+ class MessageUpdate(BaseModel):
290
+ content: Optional[str] = None
291
+ is_archived: Optional[bool] = None
292
+
293
+ class MessageResponse(BaseModel):
294
+ id: str
295
+ sender_id: str
296
+ recipient_id: str
297
+ sender_name: str
298
+ recipient_name: str
299
+ content: str
300
+ message_type: MessageType
301
+ attachment_url: Optional[str] = None
302
+ reply_to_message_id: Optional[str] = None
303
+ status: MessageStatus
304
+ is_archived: bool = False
305
+ created_at: datetime
306
+ updated_at: datetime
307
+ read_at: Optional[datetime] = None
308
+
309
+ class ConversationResponse(BaseModel):
310
+ id: str
311
+ participant_ids: List[str]
312
+ participant_names: List[str]
313
+ last_message: Optional[MessageResponse] = None
314
+ unread_count: int = 0
315
+ created_at: datetime
316
+ updated_at: datetime
317
+
318
+ class ConversationListResponse(BaseModel):
319
+ conversations: List[ConversationResponse]
320
+ total: int
321
+ page: int
322
+ limit: int
323
+
324
+ class MessageListResponse(BaseModel):
325
+ messages: List[MessageResponse]
326
+ total: int
327
+ page: int
328
+ limit: int
329
+ conversation_id: str
330
+
331
+ # --- NOTIFICATION SCHEMAS ---
332
+ class NotificationType(str, Enum):
333
+ MESSAGE = "message"
334
+ APPOINTMENT = "appointment"
335
+ SYSTEM = "system"
336
+ REMINDER = "reminder"
337
+
338
+ class NotificationPriority(str, Enum):
339
+ LOW = "low"
340
+ MEDIUM = "medium"
341
+ HIGH = "high"
342
+ URGENT = "urgent"
343
+
344
+ class NotificationCreate(BaseModel):
345
+ recipient_id: str
346
+ title: str
347
+ message: str
348
+ notification_type: NotificationType
349
+ priority: NotificationPriority = NotificationPriority.MEDIUM
350
+ data: Optional[dict] = None # Additional data for the notification
351
+
352
+ class NotificationResponse(BaseModel):
353
+ id: str
354
+ recipient_id: str
355
+ recipient_name: str
356
+ title: str
357
+ message: str
358
+ notification_type: NotificationType
359
+ priority: NotificationPriority
360
+ data: Optional[dict] = None
361
+ is_read: bool = False
362
+ created_at: datetime
363
+ read_at: Optional[datetime] = None
364
+
365
+ class NotificationListResponse(BaseModel):
366
+ notifications: List[NotificationResponse]
367
+ total: int
368
+ unread_count: int
369
+ page: int
370
+ limit: int
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ motor
4
+ python-jose[cryptography]
5
+ passlib[bcrypt]
6
+ certifi
7
+ bcrypt==4.0.1
8
+ email-validator
9
+ python-multipart
10
+ requests
11
+ gradio
12
+ python-dotenv>=0.21.0
13
+ aiohttp
utils/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Empty __init__.py to make /utils a package
2
+ pass
utils/db.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from db.mongo import patients_collection
2
+ import logging
3
+
4
+ logging.basicConfig(level=logging.INFO)
5
+ logger = logging.getLogger(__name__)
6
+
7
+ async def create_indexes():
8
+ try:
9
+ # Create indexes for patients_collection
10
+ await patients_collection.create_index("fhir_id", unique=True)
11
+ await patients_collection.create_index("full_name", background=True)
12
+ await patients_collection.create_index("date_of_birth", background=True)
13
+ await patients_collection.create_index("source", background=True)
14
+ logger.info("Indexes created successfully for patients_collection")
15
+ except Exception as e:
16
+ logger.error(f"Failed to create indexes: {str(e)}")
17
+ raise
utils/helpers.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ import re
3
+
4
+ def calculate_age(birth_date_str):
5
+ """Calculate age from a birth date string (YYYY-MM-DD)."""
6
+ if not birth_date_str:
7
+ return None
8
+ try:
9
+ birth_date = datetime.fromisoformat(birth_date_str)
10
+ today = datetime.utcnow()
11
+ age = today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))
12
+ return age
13
+ except (ValueError, TypeError):
14
+ return None
15
+
16
+ def standardize_language(language):
17
+ """Standardize language codes or text to a consistent format."""
18
+ if not language:
19
+ return "en"
20
+ language = language.lower().strip()
21
+ language_map = {
22
+ "english": "en",
23
+ "french": "fr",
24
+ "spanish": "es",
25
+ "german": "de"
26
+ }
27
+ return language_map.get(language, re.sub(r'[^a-z]', '', language)[:2])
28
+
29
+ def escape_latex_special_chars(text):
30
+ """Escape special characters for LaTeX compatibility."""
31
+ if not isinstance(text, str) or not text:
32
+ return ""
33
+ latex_special_chars = {
34
+ '&': r'\&',
35
+ '%': r'\%',
36
+ '$': r'\$',
37
+ '#': r'\#',
38
+ '_': r'\_',
39
+ '{': r'\{',
40
+ '}': r'\}',
41
+ '~': r'\textasciitilde{}',
42
+ '^': r'\textasciicircum{}',
43
+ '\\': r'\textbackslash{}'
44
+ }
45
+ return ''.join(latex_special_chars.get(c, c) for c in text)
46
+
47
+ def hyphenate_long_strings(text, max_length=30):
48
+ """Insert hyphens into long strings to prevent LaTeX table overflow."""
49
+ if not isinstance(text, str) or not text:
50
+ return ""
51
+ if len(text) <= max_length:
52
+ return text
53
+ # Insert a zero-width space (allowing LaTeX to break) every max_length characters
54
+ result = []
55
+ for i in range(0, len(text), max_length):
56
+ chunk = text[i:i + max_length]
57
+ result.append(chunk)
58
+ return r"\-".join(result)
59
+
60
+ def format_timestamp(timestamp_str):
61
+ """Format a timestamp string for display in LaTeX."""
62
+ if not timestamp_str:
63
+ return "N/A"
64
+ try:
65
+ # Handle ISO format timestamps
66
+ dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
67
+ return dt.strftime("%Y-%m-%d %H:%M")
68
+ except (ValueError, TypeError):
69
+ return str(timestamp_str)