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

Initial CPS-API deployment with TxAgent integration

Browse files
__init__.py CHANGED
@@ -1 +1,33 @@
1
- # This makes this directory a Python package
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/messaging.py CHANGED
@@ -795,8 +795,14 @@ async def upload_file(
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
 
795
  )
796
 
797
  # Create uploads directory if it doesn't exist
798
+ try:
799
+ upload_dir = Path("uploads")
800
+ upload_dir.mkdir(exist_ok=True)
801
+ except PermissionError:
802
+ # In containerized environments, use temp directory
803
+ import tempfile
804
+ upload_dir = Path(tempfile.gettempdir()) / "uploads"
805
+ upload_dir.mkdir(exist_ok=True)
806
 
807
  # Create category subdirectory
808
  category_dir = upload_dir / file_category
api/routes/patients.py CHANGED
@@ -35,7 +35,14 @@ router = APIRouter()
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):
 
35
  # Configuration
36
  BASE_DIR = Path(__file__).resolve().parent.parent.parent
37
  SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
38
+ try:
39
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
40
+ except PermissionError:
41
+ # In containerized environments, we might not have write permissions
42
+ # Use a temporary directory instead
43
+ import tempfile
44
+ SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
45
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
46
 
47
  # Pydantic models for update validation
48
  class ConditionUpdate(BaseModel):
deployment/__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()
deployment/routes.py ADDED
@@ -0,0 +1 @@
 
 
1
+ #
deployment/routes/__init__.py ADDED
File without changes
deployment/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)})
deployment/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
deployment/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)}")
deployment/routes/messaging.py ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
799
+ upload_dir = Path("uploads")
800
+ upload_dir.mkdir(exist_ok=True)
801
+ except PermissionError:
802
+ # In containerized environments, use temp directory
803
+ import tempfile
804
+ upload_dir = Path(tempfile.gettempdir()) / "uploads"
805
+ upload_dir.mkdir(exist_ok=True)
806
+
807
+ # Create category subdirectory
808
+ category_dir = upload_dir / file_category
809
+ category_dir.mkdir(exist_ok=True)
810
+
811
+ # Generate unique filename
812
+ unique_filename = f"{uuid.uuid4()}{file_ext}"
813
+ file_path = category_dir / unique_filename
814
+
815
+ try:
816
+ # Save file
817
+ with open(file_path, "wb") as buffer:
818
+ content = await file.read()
819
+ buffer.write(content)
820
+
821
+ # Return file info
822
+ return {
823
+ "filename": file.filename,
824
+ "file_url": f"/uploads/{file_category}/{unique_filename}",
825
+ "file_size": len(content),
826
+ "file_type": file_category,
827
+ "message_type": MessageType.IMAGE if file_category == 'image' else MessageType.FILE
828
+ }
829
+
830
+ except Exception as e:
831
+ # Clean up file if save fails
832
+ if file_path.exists():
833
+ file_path.unlink()
834
+ raise HTTPException(
835
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
836
+ detail=f"Failed to upload file: {str(e)}"
837
+ )
838
+
839
+ # --- STATIC FILE SERVING ---
840
+ @router.get("/uploads/{category}/{filename}")
841
+ async def serve_file(category: str, filename: str):
842
+ """Serve uploaded files"""
843
+ import os
844
+
845
+ # Get the current working directory and construct absolute path
846
+ current_dir = os.getcwd()
847
+ file_path = Path(current_dir) / "uploads" / category / filename
848
+
849
+ print(f"🔍 Looking for file: {file_path}")
850
+ print(f"📁 File exists: {file_path.exists()}")
851
+
852
+ if not file_path.exists():
853
+ raise HTTPException(
854
+ status_code=status.HTTP_404_NOT_FOUND,
855
+ detail=f"File not found: {file_path}"
856
+ )
857
+
858
+ # Determine content type based on file extension
859
+ ext = file_path.suffix.lower()
860
+ content_types = {
861
+ '.jpg': 'image/jpeg',
862
+ '.jpeg': 'image/jpeg',
863
+ '.png': 'image/png',
864
+ '.gif': 'image/gif',
865
+ '.bmp': 'image/bmp',
866
+ '.webp': 'image/webp',
867
+ '.pdf': 'application/pdf',
868
+ '.doc': 'application/msword',
869
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
870
+ '.txt': 'text/plain',
871
+ '.rtf': 'application/rtf',
872
+ '.xls': 'application/vnd.ms-excel',
873
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
874
+ '.csv': 'text/csv',
875
+ '.ppt': 'application/vnd.ms-powerpoint',
876
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
877
+ '.zip': 'application/zip',
878
+ '.rar': 'application/x-rar-compressed',
879
+ '.7z': 'application/x-7z-compressed'
880
+ }
881
+
882
+ content_type = content_types.get(ext, 'application/octet-stream')
883
+
884
+ try:
885
+ # Read and return file content
886
+ with open(file_path, "rb") as f:
887
+ content = f.read()
888
+
889
+ from fastapi.responses import Response
890
+ return Response(
891
+ content=content,
892
+ media_type=content_type,
893
+ headers={
894
+ "Content-Disposition": f"inline; filename={filename}",
895
+ "Cache-Control": "public, max-age=31536000"
896
+ }
897
+ )
898
+ except Exception as e:
899
+ print(f"❌ Error reading file: {e}")
900
+ raise HTTPException(
901
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
902
+ detail=f"Error reading file: {str(e)}"
903
+ )
deployment/routes/patients.py ADDED
@@ -0,0 +1,1153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
39
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
40
+ except PermissionError:
41
+ # In containerized environments, we might not have write permissions
42
+ # Use a temporary directory instead
43
+ import tempfile
44
+ SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
45
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
46
+
47
+ # Pydantic models for update validation
48
+ class ConditionUpdate(BaseModel):
49
+ id: Optional[str] = None
50
+ code: Optional[str] = None
51
+ status: Optional[str] = None
52
+ onset_date: Optional[str] = None
53
+ recorded_date: Optional[str] = None
54
+ verification_status: Optional[str] = None
55
+ notes: Optional[str] = None
56
+
57
+ class MedicationUpdate(BaseModel):
58
+ id: Optional[str] = None
59
+ name: Optional[str] = None
60
+ status: Optional[str] = None
61
+ prescribed_date: Optional[str] = None
62
+ requester: Optional[str] = None
63
+ dosage: Optional[str] = None
64
+
65
+ class EncounterUpdate(BaseModel):
66
+ id: Optional[str] = None
67
+ type: Optional[str] = None
68
+ status: Optional[str] = None
69
+ period: Optional[Dict[str, str]] = None
70
+ service_provider: Optional[str] = None
71
+
72
+ class NoteUpdate(BaseModel):
73
+ id: Optional[str] = None
74
+ title: Optional[str] = None
75
+ date: Optional[str] = None
76
+ author: Optional[str] = None
77
+ content: Optional[str] = None
78
+
79
+ class PatientUpdate(BaseModel):
80
+ full_name: Optional[str] = None
81
+ gender: Optional[str] = None
82
+ date_of_birth: Optional[str] = None
83
+ address: Optional[str] = None
84
+ city: Optional[str] = None
85
+ state: Optional[str] = None
86
+ postal_code: Optional[str] = None
87
+ country: Optional[str] = None
88
+ marital_status: Optional[str] = None
89
+ language: Optional[str] = None
90
+ conditions: Optional[List[ConditionUpdate]] = None
91
+ medications: Optional[List[MedicationUpdate]] = None
92
+ encounters: Optional[List[EncounterUpdate]] = None
93
+ notes: Optional[List[NoteUpdate]] = None
94
+
95
+ @router.get("/debug/count")
96
+ async def debug_patient_count():
97
+ """Debug endpoint to verify patient counts"""
98
+ try:
99
+ total = await patients_collection.count_documents({})
100
+ synthea = await patients_collection.count_documents({"source": "synthea"})
101
+ manual = await patients_collection.count_documents({"source": "manual"})
102
+ return {
103
+ "total": total,
104
+ "synthea": synthea,
105
+ "manual": manual,
106
+ "message": f"Found {total} total patients ({synthea} from synthea, {manual} manual)"
107
+ }
108
+ except Exception as e:
109
+ logger.error(f"Error counting patients: {str(e)}")
110
+ raise HTTPException(
111
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
112
+ detail=f"Error counting patients: {str(e)}"
113
+ )
114
+
115
+ @router.post("/patients", status_code=status.HTTP_201_CREATED)
116
+ async def create_patient(
117
+ patient_data: PatientCreate,
118
+ current_user: dict = Depends(get_current_user)
119
+ ):
120
+ """Create a new patient in the database"""
121
+ logger.info(f"Creating new patient by user {current_user.get('email')}")
122
+
123
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
124
+ logger.warning(f"Unauthorized create attempt by {current_user.get('email')}")
125
+ raise HTTPException(
126
+ status_code=status.HTTP_403_FORBIDDEN,
127
+ detail="Only administrators and doctors can create patients"
128
+ )
129
+
130
+ try:
131
+ # Prepare the patient document
132
+ patient_doc = patient_data.dict()
133
+ now = datetime.utcnow().isoformat()
134
+
135
+ # Add system-generated fields
136
+ patient_doc.update({
137
+ "fhir_id": str(uuid.uuid4()),
138
+ "import_date": now,
139
+ "last_updated": now,
140
+ "source": "manual",
141
+ "created_by": current_user.get('email')
142
+ })
143
+
144
+ # Ensure arrays exist even if empty
145
+ for field in ['conditions', 'medications', 'encounters', 'notes']:
146
+ if field not in patient_doc:
147
+ patient_doc[field] = []
148
+
149
+ # Insert the patient document
150
+ result = await patients_collection.insert_one(patient_doc)
151
+
152
+ # Return the created patient with the generated ID
153
+ created_patient = await patients_collection.find_one(
154
+ {"_id": result.inserted_id}
155
+ )
156
+
157
+ if not created_patient:
158
+ logger.error("Failed to retrieve created patient")
159
+ raise HTTPException(
160
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
161
+ detail="Failed to retrieve created patient"
162
+ )
163
+
164
+ created_patient["id"] = str(created_patient["_id"])
165
+ del created_patient["_id"]
166
+
167
+ logger.info(f"Successfully created patient {created_patient['fhir_id']}")
168
+ return created_patient
169
+
170
+ except Exception as e:
171
+ logger.error(f"Failed to create patient: {str(e)}")
172
+ raise HTTPException(
173
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
174
+ detail=f"Failed to create patient: {str(e)}"
175
+ )
176
+
177
+ @router.delete("/patients/{patient_id}", status_code=status.HTTP_204_NO_CONTENT)
178
+ async def delete_patient(
179
+ patient_id: str,
180
+ current_user: dict = Depends(get_current_user)
181
+ ):
182
+ """Delete a patient from the database"""
183
+ logger.info(f"Deleting patient {patient_id} by user {current_user.get('email')}")
184
+
185
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
186
+ logger.warning(f"Unauthorized delete attempt by {current_user.get('email')}")
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="Only administrators can delete patients"
190
+ )
191
+
192
+ try:
193
+ # Build the query based on whether patient_id is a valid ObjectId
194
+ query = {"fhir_id": patient_id}
195
+ if ObjectId.is_valid(patient_id):
196
+ query = {
197
+ "$or": [
198
+ {"_id": ObjectId(patient_id)},
199
+ {"fhir_id": patient_id}
200
+ ]
201
+ }
202
+
203
+ # Check if patient exists
204
+ patient = await patients_collection.find_one(query)
205
+
206
+ if not patient:
207
+ logger.warning(f"Patient not found for deletion: {patient_id}")
208
+ raise HTTPException(
209
+ status_code=status.HTTP_404_NOT_FOUND,
210
+ detail="Patient not found"
211
+ )
212
+
213
+ # Perform deletion
214
+ result = await patients_collection.delete_one(query)
215
+
216
+ if result.deleted_count == 0:
217
+ logger.error(f"Failed to delete patient {patient_id}")
218
+ raise HTTPException(
219
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
220
+ detail="Failed to delete patient"
221
+ )
222
+
223
+ logger.info(f"Successfully deleted patient {patient_id}")
224
+ return None
225
+
226
+ except HTTPException:
227
+ raise
228
+ except Exception as e:
229
+ logger.error(f"Failed to delete patient {patient_id}: {str(e)}")
230
+ raise HTTPException(
231
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
232
+ detail=f"Failed to delete patient: {str(e)}"
233
+ )
234
+
235
+ async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
236
+ logger.debug(f"Processing patient from file: {file_path}")
237
+ patient_data = {}
238
+ notes = []
239
+ conditions = []
240
+ medications = []
241
+ encounters = []
242
+
243
+ # Validate bundle structure
244
+ if not isinstance(bundle, dict) or 'entry' not in bundle:
245
+ logger.error(f"Invalid FHIR bundle structure in {file_path}")
246
+ return None
247
+
248
+ for entry in bundle.get('entry', []):
249
+ resource = entry.get('resource', {})
250
+ resource_type = resource.get('resourceType')
251
+
252
+ if not resource_type:
253
+ logger.warning(f"Skipping entry with missing resourceType in {file_path}")
254
+ continue
255
+
256
+ try:
257
+ if resource_type == 'Patient':
258
+ name = resource.get('name', [{}])[0]
259
+ address = resource.get('address', [{}])[0]
260
+
261
+ # Construct full name and remove numbers
262
+ raw_full_name = f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip()
263
+ clean_full_name = re.sub(r'\d+', '', raw_full_name).strip()
264
+
265
+ patient_data = {
266
+ 'fhir_id': resource.get('id'),
267
+ 'full_name': clean_full_name,
268
+ 'gender': resource.get('gender', 'unknown'),
269
+ 'date_of_birth': resource.get('birthDate', ''),
270
+ 'address': ' '.join(address.get('line', [''])),
271
+ 'city': address.get('city', ''),
272
+ 'state': address.get('state', ''),
273
+ 'postal_code': address.get('postalCode', ''),
274
+ 'country': address.get('country', ''),
275
+ 'marital_status': resource.get('maritalStatus', {}).get('text', ''),
276
+ 'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
277
+ 'source': 'synthea',
278
+ 'last_updated': datetime.utcnow().isoformat()
279
+ }
280
+
281
+ elif resource_type == 'Encounter':
282
+ encounter = {
283
+ 'id': resource.get('id'),
284
+ 'type': resource.get('type', [{}])[0].get('text', ''),
285
+ 'status': resource.get('status'),
286
+ 'period': resource.get('period', {}),
287
+ 'service_provider': resource.get('serviceProvider', {}).get('display', '')
288
+ }
289
+ encounters.append(encounter)
290
+
291
+ for note in resource.get('note', []):
292
+ if note.get('text'):
293
+ notes.append({
294
+ 'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
295
+ 'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
296
+ 'text': note.get('text'),
297
+ 'context': f"Encounter: {encounter.get('type')}",
298
+ 'author': 'System Generated'
299
+ })
300
+
301
+ elif resource_type == 'Condition':
302
+ conditions.append({
303
+ 'id': resource.get('id'),
304
+ 'code': resource.get('code', {}).get('text', ''),
305
+ 'status': resource.get('clinicalStatus', {}).get('text', ''),
306
+ 'onset_date': resource.get('onsetDateTime'),
307
+ 'recorded_date': resource.get('recordedDate'),
308
+ 'verification_status': resource.get('verificationStatus', {}).get('text', '')
309
+ })
310
+
311
+ elif resource_type == 'MedicationRequest':
312
+ medications.append({
313
+ 'id': resource.get('id'),
314
+ 'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
315
+ 'status': resource.get('status'),
316
+ 'prescribed_date': resource.get('authoredOn'),
317
+ 'requester': resource.get('requester', {}).get('display', ''),
318
+ 'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
319
+ })
320
+
321
+ except Exception as e:
322
+ logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
323
+ continue
324
+
325
+ if patient_data:
326
+ patient_data.update({
327
+ 'notes': notes,
328
+ 'conditions': conditions,
329
+ 'medications': medications,
330
+ 'encounters': encounters,
331
+ 'import_date': datetime.utcnow().isoformat()
332
+ })
333
+ logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
334
+ return patient_data
335
+ logger.warning(f"No valid patient data found in {file_path}")
336
+ return None
337
+
338
+ @router.post("/import", status_code=status.HTTP_201_CREATED)
339
+ async def import_patients(
340
+ limit: int = Query(100, ge=1, le=1000),
341
+ current_user: dict = Depends(get_current_user)
342
+ ):
343
+ request_id = str(uuid.uuid4())
344
+ logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
345
+ start_time = time.time()
346
+
347
+ if current_user.get('role') not in ['admin', 'doctor']:
348
+ logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
349
+ raise HTTPException(
350
+ status_code=status.HTTP_403_FORBIDDEN,
351
+ detail="Only administrators and doctors can import data"
352
+ )
353
+
354
+ try:
355
+ await create_indexes()
356
+
357
+ if not SYNTHEA_DATA_DIR.exists():
358
+ logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
359
+ raise HTTPException(
360
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
361
+ detail="Data directory not found"
362
+ )
363
+
364
+ # Filter out non-patient files
365
+ files = [
366
+ f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
367
+ if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
368
+ ]
369
+ if not files:
370
+ logger.warning("No valid patient JSON files found in synthea data directory")
371
+ return {
372
+ "status": "success",
373
+ "message": "No patient data files found",
374
+ "imported": 0,
375
+ "request_id": request_id
376
+ }
377
+
378
+ operations = []
379
+ imported = 0
380
+ errors = []
381
+
382
+ for file_path in files[:limit]:
383
+ try:
384
+ logger.debug(f"Processing file: {file_path}")
385
+
386
+ # Check file accessibility
387
+ if not os.path.exists(file_path):
388
+ logger.error(f"File not found: {file_path}")
389
+ errors.append(f"File not found: {file_path}")
390
+ continue
391
+
392
+ # Check file size
393
+ file_size = os.path.getsize(file_path)
394
+ if file_size == 0:
395
+ logger.warning(f"Empty file: {file_path}")
396
+ errors.append(f"Empty file: {file_path}")
397
+ continue
398
+
399
+ with open(file_path, 'r', encoding='utf-8') as f:
400
+ try:
401
+ bundle = json.load(f)
402
+ except json.JSONDecodeError as je:
403
+ logger.error(f"Invalid JSON in {file_path}: {str(je)}")
404
+ errors.append(f"Invalid JSON in {file_path}: {str(je)}")
405
+ continue
406
+
407
+ patient = await process_synthea_patient(bundle, file_path)
408
+ if patient:
409
+ if not patient.get('fhir_id'):
410
+ logger.warning(f"Missing FHIR ID in patient data from {file_path}")
411
+ errors.append(f"Missing FHIR ID in {file_path}")
412
+ continue
413
+
414
+ operations.append(UpdateOne(
415
+ {"fhir_id": patient['fhir_id']},
416
+ {"$setOnInsert": patient},
417
+ upsert=True
418
+ ))
419
+ imported += 1
420
+ else:
421
+ logger.warning(f"No valid patient data in {file_path}")
422
+ errors.append(f"No valid patient data in {file_path}")
423
+
424
+ except Exception as e:
425
+ logger.error(f"Error processing {file_path}: {str(e)}")
426
+ errors.append(f"Error in {file_path}: {str(e)}")
427
+ continue
428
+
429
+ response = {
430
+ "status": "success",
431
+ "imported": imported,
432
+ "errors": errors,
433
+ "request_id": request_id,
434
+ "duration_seconds": time.time() - start_time
435
+ }
436
+
437
+ if operations:
438
+ try:
439
+ result = await patients_collection.bulk_write(operations, ordered=False)
440
+ response.update({
441
+ "upserted": result.upserted_count,
442
+ "existing": len(operations) - result.upserted_count
443
+ })
444
+ logger.info(f"Import request {request_id} completed: {imported} patients processed, "
445
+ f"{result.upserted_count} upserted, {len(errors)} errors")
446
+ except BulkWriteError as bwe:
447
+ logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
448
+ response.update({
449
+ "upserted": bwe.details.get('nUpserted', 0),
450
+ "existing": len(operations) - bwe.details.get('nUpserted', 0),
451
+ "write_errors": [
452
+ f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
453
+ ]
454
+ })
455
+ logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
456
+ f"{response['upserted']} upserted, {len(errors)} errors")
457
+ except Exception as e:
458
+ logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
459
+ raise HTTPException(
460
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
461
+ detail=f"Database operation failed: {str(e)}"
462
+ )
463
+ else:
464
+ logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
465
+ response["message"] = "No new patients found to import"
466
+
467
+ return response
468
+
469
+ except HTTPException:
470
+ raise
471
+ except Exception as e:
472
+ logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
473
+ raise HTTPException(
474
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
475
+ detail=f"Import failed: {str(e)}"
476
+ )
477
+
478
+ @router.post("/patients/import-ehr", status_code=status.HTTP_201_CREATED)
479
+ async def import_ehr_patients(
480
+ ehr_data: List[dict],
481
+ ehr_system: str = Query(..., description="Name of the EHR system"),
482
+ current_user: dict = Depends(get_current_user),
483
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
484
+ ):
485
+ """Import patients from external EHR system"""
486
+ logger.info(f"Importing {len(ehr_data)} patients from EHR system: {ehr_system}")
487
+
488
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
489
+ logger.warning(f"Unauthorized EHR import attempt by {current_user.get('email')}")
490
+ raise HTTPException(
491
+ status_code=status.HTTP_403_FORBIDDEN,
492
+ detail="Only administrators and doctors can import EHR patients"
493
+ )
494
+
495
+ try:
496
+ imported_patients = []
497
+ skipped_patients = []
498
+
499
+ for patient_data in ehr_data:
500
+ # Check if patient already exists by multiple criteria
501
+ existing_patient = await patients_collection.find_one({
502
+ "$or": [
503
+ {"ehr_id": patient_data.get("ehr_id"), "ehr_system": ehr_system},
504
+ {"full_name": patient_data.get("full_name"), "date_of_birth": patient_data.get("date_of_birth")},
505
+ {"national_id": patient_data.get("national_id")} if patient_data.get("national_id") else {}
506
+ ]
507
+ })
508
+
509
+ if existing_patient:
510
+ skipped_patients.append(patient_data.get("full_name", "Unknown"))
511
+ logger.info(f"Patient {patient_data.get('full_name', 'Unknown')} already exists, skipping...")
512
+ continue
513
+
514
+ # Prepare patient document for EHR import
515
+ patient_doc = {
516
+ "full_name": patient_data.get("full_name"),
517
+ "date_of_birth": patient_data.get("date_of_birth"),
518
+ "gender": patient_data.get("gender"),
519
+ "address": patient_data.get("address"),
520
+ "national_id": patient_data.get("national_id"),
521
+ "blood_type": patient_data.get("blood_type"),
522
+ "allergies": patient_data.get("allergies", []),
523
+ "chronic_conditions": patient_data.get("chronic_conditions", []),
524
+ "medications": patient_data.get("medications", []),
525
+ "emergency_contact_name": patient_data.get("emergency_contact_name"),
526
+ "emergency_contact_phone": patient_data.get("emergency_contact_phone"),
527
+ "insurance_provider": patient_data.get("insurance_provider"),
528
+ "insurance_policy_number": patient_data.get("insurance_policy_number"),
529
+ "contact": patient_data.get("contact"),
530
+ "source": "ehr",
531
+ "ehr_id": patient_data.get("ehr_id"),
532
+ "ehr_system": ehr_system,
533
+ "status": "active",
534
+ "registration_date": datetime.now(),
535
+ "created_by": current_user.get('email'),
536
+ "created_at": datetime.now(),
537
+ "updated_at": datetime.now()
538
+ }
539
+
540
+ # Insert patient
541
+ result = await patients_collection.insert_one(patient_doc)
542
+ imported_patients.append(patient_data.get("full_name", "Unknown"))
543
+
544
+ logger.info(f"Successfully imported {len(imported_patients)} patients, skipped {len(skipped_patients)}")
545
+
546
+ return {
547
+ "message": f"Successfully imported {len(imported_patients)} patients from {ehr_system}",
548
+ "imported_count": len(imported_patients),
549
+ "skipped_count": len(skipped_patients),
550
+ "imported_patients": imported_patients,
551
+ "skipped_patients": skipped_patients
552
+ }
553
+
554
+ except Exception as e:
555
+ logger.error(f"Error importing EHR patients: {str(e)}")
556
+ raise HTTPException(
557
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
558
+ detail=f"Error importing EHR patients: {str(e)}"
559
+ )
560
+
561
+ @router.get("/patients/sources", response_model=List[dict])
562
+ async def get_patient_sources(
563
+ current_user: dict = Depends(get_current_user)
564
+ ):
565
+ """Get available patient sources and their counts"""
566
+ try:
567
+ # Get counts for each source
568
+ source_counts = await patients_collection.aggregate([
569
+ {
570
+ "$group": {
571
+ "_id": "$source",
572
+ "count": {"$sum": 1}
573
+ }
574
+ }
575
+ ]).to_list(length=None)
576
+
577
+ # Format the response
578
+ sources = []
579
+ for source_count in source_counts:
580
+ source_name = source_count["_id"] or "unknown"
581
+ sources.append({
582
+ "source": source_name,
583
+ "count": source_count["count"],
584
+ "label": source_name.replace("_", " ").title()
585
+ })
586
+
587
+ return sources
588
+
589
+ except Exception as e:
590
+ logger.error(f"Error getting patient sources: {str(e)}")
591
+ raise HTTPException(
592
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
593
+ detail=f"Error getting patient sources: {str(e)}"
594
+ )
595
+
596
+ @router.get("/patients", response_model=PatientListResponse)
597
+ async def get_patients(
598
+ page: int = Query(1, ge=1),
599
+ limit: int = Query(20, ge=1, le=100),
600
+ search: Optional[str] = Query(None),
601
+ source: Optional[str] = Query(None), # Filter by patient source
602
+ patient_status: Optional[str] = Query(None), # Filter by patient status
603
+ doctor_id: Optional[str] = Query(None), # Filter by assigned doctor
604
+ current_user: dict = Depends(get_current_user),
605
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
606
+ ):
607
+ """Get patients with filtering options"""
608
+ skip = (page - 1) * limit
609
+ user_id = current_user["_id"]
610
+
611
+ # Debug logging
612
+ logger.info(f"🔍 Getting patients for user: {current_user.get('email')} with roles: {current_user.get('roles', [])}")
613
+
614
+ # Build filter query
615
+ filter_query = {}
616
+
617
+ # Role-based access - apply this first
618
+ if 'admin' not in current_user.get('roles', []):
619
+ if 'doctor' in current_user.get('roles', []):
620
+ # Doctors can see all patients for now (temporarily simplified)
621
+ logger.info("👨‍⚕️ Doctor access - no restrictions applied")
622
+ pass # No restrictions for doctors
623
+ else:
624
+ # Patients can only see their own record
625
+ logger.info(f"👤 Patient access - restricting to own record: {user_id}")
626
+ filter_query["_id"] = ObjectId(user_id)
627
+
628
+ # Build additional filters
629
+ additional_filters = {}
630
+
631
+ # Add search filter
632
+ if search:
633
+ additional_filters["$or"] = [
634
+ {"full_name": {"$regex": search, "$options": "i"}},
635
+ {"national_id": {"$regex": search, "$options": "i"}},
636
+ {"ehr_id": {"$regex": search, "$options": "i"}}
637
+ ]
638
+
639
+ # Add source filter
640
+ if source:
641
+ additional_filters["source"] = source
642
+
643
+ # Add status filter
644
+ if patient_status:
645
+ additional_filters["status"] = patient_status
646
+
647
+ # Add doctor assignment filter
648
+ if doctor_id:
649
+ additional_filters["assigned_doctor_id"] = ObjectId(doctor_id)
650
+
651
+ # Combine filters
652
+ if additional_filters:
653
+ if filter_query.get("$or"):
654
+ # If we have role-based $or, we need to combine with additional filters
655
+ # Create a new $and condition
656
+ filter_query = {
657
+ "$and": [
658
+ filter_query,
659
+ additional_filters
660
+ ]
661
+ }
662
+ else:
663
+ # No role-based restrictions, just use additional filters
664
+ filter_query.update(additional_filters)
665
+
666
+ logger.info(f"🔍 Final filter query: {filter_query}")
667
+
668
+ try:
669
+ # Get total count
670
+ total = await patients_collection.count_documents(filter_query)
671
+ logger.info(f"📊 Total patients matching filter: {total}")
672
+
673
+ # Get patients with pagination
674
+ patients_cursor = patients_collection.find(filter_query).skip(skip).limit(limit)
675
+ patients = await patients_cursor.to_list(length=limit)
676
+ logger.info(f"📋 Retrieved {len(patients)} patients")
677
+
678
+ # Process patients to include doctor names and format dates
679
+ processed_patients = []
680
+ for patient in patients:
681
+ # Get assigned doctor name if exists
682
+ assigned_doctor_name = None
683
+ if patient.get("assigned_doctor_id"):
684
+ doctor = await db_client.users.find_one({"_id": patient["assigned_doctor_id"]})
685
+ if doctor:
686
+ assigned_doctor_name = doctor.get("full_name")
687
+
688
+ # Convert ObjectId to string
689
+ patient["id"] = str(patient["_id"])
690
+ del patient["_id"]
691
+
692
+ # Add assigned doctor name
693
+ patient["assigned_doctor_name"] = assigned_doctor_name
694
+
695
+ processed_patients.append(patient)
696
+
697
+ logger.info(f"✅ Returning {len(processed_patients)} processed patients")
698
+
699
+ return PatientListResponse(
700
+ patients=processed_patients,
701
+ total=total,
702
+ page=page,
703
+ limit=limit,
704
+ source_filter=source,
705
+ status_filter=patient_status
706
+ )
707
+
708
+ except Exception as e:
709
+ logger.error(f"❌ Error fetching patients: {str(e)}")
710
+ raise HTTPException(
711
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
712
+ detail=f"Error fetching patients: {str(e)}"
713
+ )
714
+
715
+ @router.get("/patients/{patient_id}", response_model=dict)
716
+ async def get_patient(patient_id: str):
717
+ logger.info(f"Retrieving patient: {patient_id}")
718
+ try:
719
+ patient = await patients_collection.find_one({
720
+ "$or": [
721
+ {"_id": ObjectId(patient_id)},
722
+ {"fhir_id": patient_id}
723
+ ]
724
+ })
725
+
726
+ if not patient:
727
+ logger.warning(f"Patient not found: {patient_id}")
728
+ raise HTTPException(
729
+ status_code=status.HTTP_404_NOT_FOUND,
730
+ detail="Patient not found"
731
+ )
732
+
733
+ response = {
734
+ "demographics": {
735
+ "id": str(patient["_id"]),
736
+ "fhir_id": patient.get("fhir_id"),
737
+ "full_name": patient.get("full_name"),
738
+ "gender": patient.get("gender"),
739
+ "date_of_birth": patient.get("date_of_birth"),
740
+ "age": calculate_age(patient.get("date_of_birth")),
741
+ "address": {
742
+ "line": patient.get("address"),
743
+ "city": patient.get("city"),
744
+ "state": patient.get("state"),
745
+ "postal_code": patient.get("postal_code"),
746
+ "country": patient.get("country")
747
+ },
748
+ "marital_status": patient.get("marital_status"),
749
+ "language": patient.get("language")
750
+ },
751
+ "clinical_data": {
752
+ "notes": patient.get("notes", []),
753
+ "conditions": patient.get("conditions", []),
754
+ "medications": patient.get("medications", []),
755
+ "encounters": patient.get("encounters", [])
756
+ },
757
+ "metadata": {
758
+ "source": patient.get("source"),
759
+ "import_date": patient.get("import_date"),
760
+ "last_updated": patient.get("last_updated")
761
+ }
762
+ }
763
+
764
+ logger.info(f"Successfully retrieved patient: {patient_id}")
765
+ return response
766
+
767
+ except ValueError as ve:
768
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
769
+ raise HTTPException(
770
+ status_code=status.HTTP_400_BAD_REQUEST,
771
+ detail="Invalid patient ID format"
772
+ )
773
+ except Exception as e:
774
+ logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
775
+ raise HTTPException(
776
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
777
+ detail=f"Failed to retrieve patient: {str(e)}"
778
+ )
779
+
780
+ @router.post("/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
781
+ async def add_note(
782
+ patient_id: str,
783
+ note: Note,
784
+ current_user: dict = Depends(get_current_user)
785
+ ):
786
+ logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
787
+ if current_user.get('role') not in ['doctor', 'admin']:
788
+ logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
789
+ raise HTTPException(
790
+ status_code=status.HTTP_403_FORBIDDEN,
791
+ detail="Only clinicians can add notes"
792
+ )
793
+
794
+ try:
795
+ note_data = note.dict()
796
+ note_data.update({
797
+ "author": current_user.get('full_name', 'System'),
798
+ "timestamp": datetime.utcnow().isoformat()
799
+ })
800
+
801
+ result = await patients_collection.update_one(
802
+ {"$or": [
803
+ {"_id": ObjectId(patient_id)},
804
+ {"fhir_id": patient_id}
805
+ ]},
806
+ {
807
+ "$push": {"notes": note_data},
808
+ "$set": {"last_updated": datetime.utcnow().isoformat()}
809
+ }
810
+ )
811
+
812
+ if result.modified_count == 0:
813
+ logger.warning(f"Patient not found for note addition: {patient_id}")
814
+ raise HTTPException(
815
+ status_code=status.HTTP_404_NOT_FOUND,
816
+ detail="Patient not found"
817
+ )
818
+
819
+ logger.info(f"Note added successfully for patient {patient_id}")
820
+ return {"status": "success", "message": "Note added"}
821
+
822
+ except ValueError as ve:
823
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
824
+ raise HTTPException(
825
+ status_code=status.HTTP_400_BAD_REQUEST,
826
+ detail="Invalid patient ID format"
827
+ )
828
+ except Exception as e:
829
+ logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
830
+ raise HTTPException(
831
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
832
+ detail=f"Failed to add note: {str(e)}"
833
+ )
834
+
835
+ @router.put("/patients/{patient_id}", status_code=status.HTTP_200_OK)
836
+ async def update_patient(
837
+ patient_id: str,
838
+ update_data: PatientUpdate,
839
+ current_user: dict = Depends(get_current_user)
840
+ ):
841
+ """Update a patient's record in the database"""
842
+ logger.info(f"Updating patient {patient_id} by user {current_user.get('email')}")
843
+
844
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
845
+ logger.warning(f"Unauthorized update attempt by {current_user.get('email')}")
846
+ raise HTTPException(
847
+ status_code=status.HTTP_403_FORBIDDEN,
848
+ detail="Only administrators and doctors can update patients"
849
+ )
850
+
851
+ try:
852
+ # Build the query based on whether patient_id is a valid ObjectId
853
+ query = {"fhir_id": patient_id}
854
+ if ObjectId.is_valid(patient_id):
855
+ query = {
856
+ "$or": [
857
+ {"_id": ObjectId(patient_id)},
858
+ {"fhir_id": patient_id}
859
+ ]
860
+ }
861
+
862
+ # Check if patient exists
863
+ patient = await patients_collection.find_one(query)
864
+ if not patient:
865
+ logger.warning(f"Patient not found for update: {patient_id}")
866
+ raise HTTPException(
867
+ status_code=status.HTTP_404_NOT_FOUND,
868
+ detail="Patient not found"
869
+ )
870
+
871
+ # Prepare update operations
872
+ update_ops = {"$set": {"last_updated": datetime.utcnow().isoformat()}}
873
+
874
+ # Handle demographic updates
875
+ demographics = {
876
+ "full_name": update_data.full_name,
877
+ "gender": update_data.gender,
878
+ "date_of_birth": update_data.date_of_birth,
879
+ "address": update_data.address,
880
+ "city": update_data.city,
881
+ "state": update_data.state,
882
+ "postal_code": update_data.postal_code,
883
+ "country": update_data.country,
884
+ "marital_status": update_data.marital_status,
885
+ "language": update_data.language
886
+ }
887
+ for key, value in demographics.items():
888
+ if value is not None:
889
+ update_ops["$set"][key] = value
890
+
891
+ # Handle array updates (conditions, medications, encounters, notes)
892
+ array_fields = {
893
+ "conditions": update_data.conditions,
894
+ "medications": update_data.medications,
895
+ "encounters": update_data.encounters,
896
+ "notes": update_data.notes
897
+ }
898
+
899
+ for field, items in array_fields.items():
900
+ if items is not None:
901
+ # Fetch existing items
902
+ existing_items = patient.get(field, [])
903
+ updated_items = []
904
+
905
+ for item in items:
906
+ item_dict = item.dict(exclude_unset=True)
907
+ if not item_dict:
908
+ continue
909
+
910
+ # Generate ID for new items
911
+ if not item_dict.get("id"):
912
+ item_dict["id"] = str(uuid.uuid4())
913
+
914
+ # Validate required fields
915
+ if field == "conditions" and not item_dict.get("code"):
916
+ raise HTTPException(
917
+ status_code=status.HTTP_400_BAD_REQUEST,
918
+ detail=f"Condition code is required for {field}"
919
+ )
920
+ if field == "medications" and not item_dict.get("name"):
921
+ raise HTTPException(
922
+ status_code=status.HTTP_400_BAD_REQUEST,
923
+ detail=f"Medication name is required for {field}"
924
+ )
925
+ if field == "encounters" and not item_dict.get("type"):
926
+ raise HTTPException(
927
+ status_code=status.HTTP_400_BAD_REQUEST,
928
+ detail=f"Encounter type is required for {field}"
929
+ )
930
+ if field == "notes" and not item_dict.get("content"):
931
+ raise HTTPException(
932
+ status_code=status.HTTP_400_BAD_REQUEST,
933
+ detail=f"Note content is required for {field}"
934
+ )
935
+
936
+ updated_items.append(item_dict)
937
+
938
+ # Replace the entire array
939
+ update_ops["$set"][field] = updated_items
940
+
941
+ # Perform the update
942
+ result = await patients_collection.update_one(query, update_ops)
943
+
944
+ if result.modified_count == 0 and result.matched_count == 0:
945
+ logger.warning(f"Patient not found for update: {patient_id}")
946
+ raise HTTPException(
947
+ status_code=status.HTTP_404_NOT_FOUND,
948
+ detail="Patient not found"
949
+ )
950
+
951
+ # Retrieve and return the updated patient
952
+ updated_patient = await patients_collection.find_one(query)
953
+ if not updated_patient:
954
+ logger.error(f"Failed to retrieve updated patient: {patient_id}")
955
+ raise HTTPException(
956
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
957
+ detail="Failed to retrieve updated patient"
958
+ )
959
+
960
+ response = {
961
+ "id": str(updated_patient["_id"]),
962
+ "fhir_id": updated_patient.get("fhir_id"),
963
+ "full_name": updated_patient.get("full_name"),
964
+ "gender": updated_patient.get("gender"),
965
+ "date_of_birth": updated_patient.get("date_of_birth"),
966
+ "address": updated_patient.get("address"),
967
+ "city": updated_patient.get("city"),
968
+ "state": updated_patient.get("state"),
969
+ "postal_code": updated_patient.get("postal_code"),
970
+ "country": updated_patient.get("country"),
971
+ "marital_status": updated_patient.get("marital_status"),
972
+ "language": updated_patient.get("language"),
973
+ "conditions": updated_patient.get("conditions", []),
974
+ "medications": updated_patient.get("medications", []),
975
+ "encounters": updated_patient.get("encounters", []),
976
+ "notes": updated_patient.get("notes", []),
977
+ "source": updated_patient.get("source"),
978
+ "import_date": updated_patient.get("import_date"),
979
+ "last_updated": updated_patient.get("last_updated")
980
+ }
981
+
982
+ logger.info(f"Successfully updated patient {patient_id}")
983
+ return response
984
+
985
+ except ValueError as ve:
986
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
987
+ raise HTTPException(
988
+ status_code=status.HTTP_400_BAD_REQUEST,
989
+ detail="Invalid patient ID format"
990
+ )
991
+ except HTTPException:
992
+ raise
993
+ except Exception as e:
994
+ logger.error(f"Failed to update patient {patient_id}: {str(e)}")
995
+ raise HTTPException(
996
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
997
+ detail=f"Failed to update patient: {str(e)}"
998
+ )
999
+
1000
+ # FHIR Integration Endpoints
1001
+ @router.post("/patients/import-hapi-fhir", status_code=status.HTTP_201_CREATED)
1002
+ async def import_hapi_patients(
1003
+ limit: int = Query(20, ge=1, le=100, description="Number of patients to import"),
1004
+ current_user: dict = Depends(get_current_user)
1005
+ ):
1006
+ """
1007
+ Import patients from HAPI FHIR Test Server
1008
+ """
1009
+ try:
1010
+ service = HAPIFHIRIntegrationService()
1011
+ result = await service.import_patients_from_hapi(limit=limit)
1012
+
1013
+ # Create detailed message
1014
+ message_parts = []
1015
+ if result["imported_count"] > 0:
1016
+ message_parts.append(f"Successfully imported {result['imported_count']} patients")
1017
+ if result["skipped_count"] > 0:
1018
+ message_parts.append(f"Skipped {result['skipped_count']} duplicate patients")
1019
+ if result["errors"]:
1020
+ message_parts.append(f"Encountered {len(result['errors'])} errors")
1021
+
1022
+ message = ". ".join(message_parts) + " from HAPI FHIR"
1023
+
1024
+ return {
1025
+ "message": message,
1026
+ "imported_count": result["imported_count"],
1027
+ "skipped_count": result["skipped_count"],
1028
+ "total_found": result["total_found"],
1029
+ "imported_patients": result["imported_patients"],
1030
+ "skipped_patients": result["skipped_patients"],
1031
+ "errors": result["errors"],
1032
+ "source": "hapi_fhir"
1033
+ }
1034
+
1035
+ except Exception as e:
1036
+ logger.error(f"Error importing HAPI FHIR patients: {str(e)}")
1037
+ raise HTTPException(
1038
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1039
+ detail=f"Failed to import patients from HAPI FHIR: {str(e)}"
1040
+ )
1041
+
1042
+ @router.post("/patients/sync-patient/{patient_id}")
1043
+ async def sync_patient_data(
1044
+ patient_id: str,
1045
+ current_user: dict = Depends(get_current_user)
1046
+ ):
1047
+ """
1048
+ Sync a specific patient's data from HAPI FHIR
1049
+ """
1050
+ try:
1051
+ service = HAPIFHIRIntegrationService()
1052
+ success = await service.sync_patient_data(patient_id)
1053
+
1054
+ if success:
1055
+ return {
1056
+ "message": f"Successfully synced patient {patient_id} from HAPI FHIR",
1057
+ "patient_id": patient_id,
1058
+ "success": True
1059
+ }
1060
+ else:
1061
+ raise HTTPException(
1062
+ status_code=status.HTTP_404_NOT_FOUND,
1063
+ detail=f"Patient {patient_id} not found in HAPI FHIR or sync failed"
1064
+ )
1065
+
1066
+ except HTTPException:
1067
+ raise
1068
+ except Exception as e:
1069
+ logger.error(f"Error syncing patient {patient_id}: {str(e)}")
1070
+ raise HTTPException(
1071
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1072
+ detail=f"Failed to sync patient: {str(e)}"
1073
+ )
1074
+
1075
+ @router.get("/patients/hapi-fhir/patients")
1076
+ async def get_hapi_patients(
1077
+ limit: int = Query(50, ge=1, le=200, description="Number of patients to fetch"),
1078
+ current_user: dict = Depends(get_current_user)
1079
+ ):
1080
+ """
1081
+ Get patients from HAPI FHIR without importing them
1082
+ """
1083
+ try:
1084
+ service = HAPIFHIRIntegrationService()
1085
+ patients = await service.get_hapi_patients(limit=limit)
1086
+
1087
+ return {
1088
+ "patients": patients,
1089
+ "count": len(patients),
1090
+ "source": "hapi_fhir"
1091
+ }
1092
+
1093
+ except Exception as e:
1094
+ logger.error(f"Error fetching HAPI FHIR patients: {str(e)}")
1095
+ raise HTTPException(
1096
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1097
+ detail=f"Failed to fetch patients from HAPI FHIR: {str(e)}"
1098
+ )
1099
+
1100
+ @router.get("/patients/hapi-fhir/patients/{patient_id}")
1101
+ async def get_hapi_patient_details(
1102
+ patient_id: str,
1103
+ current_user: dict = Depends(get_current_user)
1104
+ ):
1105
+ """
1106
+ Get detailed information for a specific HAPI FHIR patient
1107
+ """
1108
+ try:
1109
+ service = HAPIFHIRIntegrationService()
1110
+ patient_details = await service.get_hapi_patient_details(patient_id)
1111
+
1112
+ if not patient_details:
1113
+ raise HTTPException(
1114
+ status_code=status.HTTP_404_NOT_FOUND,
1115
+ detail=f"Patient {patient_id} not found in HAPI FHIR"
1116
+ )
1117
+
1118
+ return patient_details
1119
+
1120
+ except HTTPException:
1121
+ raise
1122
+ except Exception as e:
1123
+ logger.error(f"Error fetching HAPI FHIR patient details: {str(e)}")
1124
+ raise HTTPException(
1125
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1126
+ detail=f"Failed to fetch patient details from HAPI FHIR: {str(e)}"
1127
+ )
1128
+
1129
+ @router.get("/patients/hapi-fhir/statistics")
1130
+ async def get_hapi_statistics(
1131
+ current_user: dict = Depends(get_current_user)
1132
+ ):
1133
+ """
1134
+ Get statistics about HAPI FHIR imported patients
1135
+ """
1136
+ try:
1137
+ service = HAPIFHIRIntegrationService()
1138
+ stats = await service.get_patient_statistics()
1139
+
1140
+ return {
1141
+ "statistics": stats,
1142
+ "source": "hapi_fhir"
1143
+ }
1144
+
1145
+ except Exception as e:
1146
+ logger.error(f"Error getting HAPI FHIR statistics: {str(e)}")
1147
+ raise HTTPException(
1148
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1149
+ detail=f"Failed to get HAPI FHIR statistics: {str(e)}"
1150
+ )
1151
+
1152
+ # Export the router as 'patients' for api.__init__.py
1153
+ patients = router
deployment/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
deployment/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")
deployment/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
deployment/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()
deployment/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'
routes.py ADDED
@@ -0,0 +1 @@
 
 
1
+ #
routes/__init__.py ADDED
File without changes
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)})
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
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)}")
routes/messaging.py ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
799
+ upload_dir = Path("uploads")
800
+ upload_dir.mkdir(exist_ok=True)
801
+ except PermissionError:
802
+ # In containerized environments, use temp directory
803
+ import tempfile
804
+ upload_dir = Path(tempfile.gettempdir()) / "uploads"
805
+ upload_dir.mkdir(exist_ok=True)
806
+
807
+ # Create category subdirectory
808
+ category_dir = upload_dir / file_category
809
+ category_dir.mkdir(exist_ok=True)
810
+
811
+ # Generate unique filename
812
+ unique_filename = f"{uuid.uuid4()}{file_ext}"
813
+ file_path = category_dir / unique_filename
814
+
815
+ try:
816
+ # Save file
817
+ with open(file_path, "wb") as buffer:
818
+ content = await file.read()
819
+ buffer.write(content)
820
+
821
+ # Return file info
822
+ return {
823
+ "filename": file.filename,
824
+ "file_url": f"/uploads/{file_category}/{unique_filename}",
825
+ "file_size": len(content),
826
+ "file_type": file_category,
827
+ "message_type": MessageType.IMAGE if file_category == 'image' else MessageType.FILE
828
+ }
829
+
830
+ except Exception as e:
831
+ # Clean up file if save fails
832
+ if file_path.exists():
833
+ file_path.unlink()
834
+ raise HTTPException(
835
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
836
+ detail=f"Failed to upload file: {str(e)}"
837
+ )
838
+
839
+ # --- STATIC FILE SERVING ---
840
+ @router.get("/uploads/{category}/{filename}")
841
+ async def serve_file(category: str, filename: str):
842
+ """Serve uploaded files"""
843
+ import os
844
+
845
+ # Get the current working directory and construct absolute path
846
+ current_dir = os.getcwd()
847
+ file_path = Path(current_dir) / "uploads" / category / filename
848
+
849
+ print(f"🔍 Looking for file: {file_path}")
850
+ print(f"📁 File exists: {file_path.exists()}")
851
+
852
+ if not file_path.exists():
853
+ raise HTTPException(
854
+ status_code=status.HTTP_404_NOT_FOUND,
855
+ detail=f"File not found: {file_path}"
856
+ )
857
+
858
+ # Determine content type based on file extension
859
+ ext = file_path.suffix.lower()
860
+ content_types = {
861
+ '.jpg': 'image/jpeg',
862
+ '.jpeg': 'image/jpeg',
863
+ '.png': 'image/png',
864
+ '.gif': 'image/gif',
865
+ '.bmp': 'image/bmp',
866
+ '.webp': 'image/webp',
867
+ '.pdf': 'application/pdf',
868
+ '.doc': 'application/msword',
869
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
870
+ '.txt': 'text/plain',
871
+ '.rtf': 'application/rtf',
872
+ '.xls': 'application/vnd.ms-excel',
873
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
874
+ '.csv': 'text/csv',
875
+ '.ppt': 'application/vnd.ms-powerpoint',
876
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
877
+ '.zip': 'application/zip',
878
+ '.rar': 'application/x-rar-compressed',
879
+ '.7z': 'application/x-7z-compressed'
880
+ }
881
+
882
+ content_type = content_types.get(ext, 'application/octet-stream')
883
+
884
+ try:
885
+ # Read and return file content
886
+ with open(file_path, "rb") as f:
887
+ content = f.read()
888
+
889
+ from fastapi.responses import Response
890
+ return Response(
891
+ content=content,
892
+ media_type=content_type,
893
+ headers={
894
+ "Content-Disposition": f"inline; filename={filename}",
895
+ "Cache-Control": "public, max-age=31536000"
896
+ }
897
+ )
898
+ except Exception as e:
899
+ print(f"❌ Error reading file: {e}")
900
+ raise HTTPException(
901
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
902
+ detail=f"Error reading file: {str(e)}"
903
+ )
routes/patients.py ADDED
@@ -0,0 +1,1153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
39
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
40
+ except PermissionError:
41
+ # In containerized environments, we might not have write permissions
42
+ # Use a temporary directory instead
43
+ import tempfile
44
+ SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
45
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
46
+
47
+ # Pydantic models for update validation
48
+ class ConditionUpdate(BaseModel):
49
+ id: Optional[str] = None
50
+ code: Optional[str] = None
51
+ status: Optional[str] = None
52
+ onset_date: Optional[str] = None
53
+ recorded_date: Optional[str] = None
54
+ verification_status: Optional[str] = None
55
+ notes: Optional[str] = None
56
+
57
+ class MedicationUpdate(BaseModel):
58
+ id: Optional[str] = None
59
+ name: Optional[str] = None
60
+ status: Optional[str] = None
61
+ prescribed_date: Optional[str] = None
62
+ requester: Optional[str] = None
63
+ dosage: Optional[str] = None
64
+
65
+ class EncounterUpdate(BaseModel):
66
+ id: Optional[str] = None
67
+ type: Optional[str] = None
68
+ status: Optional[str] = None
69
+ period: Optional[Dict[str, str]] = None
70
+ service_provider: Optional[str] = None
71
+
72
+ class NoteUpdate(BaseModel):
73
+ id: Optional[str] = None
74
+ title: Optional[str] = None
75
+ date: Optional[str] = None
76
+ author: Optional[str] = None
77
+ content: Optional[str] = None
78
+
79
+ class PatientUpdate(BaseModel):
80
+ full_name: Optional[str] = None
81
+ gender: Optional[str] = None
82
+ date_of_birth: Optional[str] = None
83
+ address: Optional[str] = None
84
+ city: Optional[str] = None
85
+ state: Optional[str] = None
86
+ postal_code: Optional[str] = None
87
+ country: Optional[str] = None
88
+ marital_status: Optional[str] = None
89
+ language: Optional[str] = None
90
+ conditions: Optional[List[ConditionUpdate]] = None
91
+ medications: Optional[List[MedicationUpdate]] = None
92
+ encounters: Optional[List[EncounterUpdate]] = None
93
+ notes: Optional[List[NoteUpdate]] = None
94
+
95
+ @router.get("/debug/count")
96
+ async def debug_patient_count():
97
+ """Debug endpoint to verify patient counts"""
98
+ try:
99
+ total = await patients_collection.count_documents({})
100
+ synthea = await patients_collection.count_documents({"source": "synthea"})
101
+ manual = await patients_collection.count_documents({"source": "manual"})
102
+ return {
103
+ "total": total,
104
+ "synthea": synthea,
105
+ "manual": manual,
106
+ "message": f"Found {total} total patients ({synthea} from synthea, {manual} manual)"
107
+ }
108
+ except Exception as e:
109
+ logger.error(f"Error counting patients: {str(e)}")
110
+ raise HTTPException(
111
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
112
+ detail=f"Error counting patients: {str(e)}"
113
+ )
114
+
115
+ @router.post("/patients", status_code=status.HTTP_201_CREATED)
116
+ async def create_patient(
117
+ patient_data: PatientCreate,
118
+ current_user: dict = Depends(get_current_user)
119
+ ):
120
+ """Create a new patient in the database"""
121
+ logger.info(f"Creating new patient by user {current_user.get('email')}")
122
+
123
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
124
+ logger.warning(f"Unauthorized create attempt by {current_user.get('email')}")
125
+ raise HTTPException(
126
+ status_code=status.HTTP_403_FORBIDDEN,
127
+ detail="Only administrators and doctors can create patients"
128
+ )
129
+
130
+ try:
131
+ # Prepare the patient document
132
+ patient_doc = patient_data.dict()
133
+ now = datetime.utcnow().isoformat()
134
+
135
+ # Add system-generated fields
136
+ patient_doc.update({
137
+ "fhir_id": str(uuid.uuid4()),
138
+ "import_date": now,
139
+ "last_updated": now,
140
+ "source": "manual",
141
+ "created_by": current_user.get('email')
142
+ })
143
+
144
+ # Ensure arrays exist even if empty
145
+ for field in ['conditions', 'medications', 'encounters', 'notes']:
146
+ if field not in patient_doc:
147
+ patient_doc[field] = []
148
+
149
+ # Insert the patient document
150
+ result = await patients_collection.insert_one(patient_doc)
151
+
152
+ # Return the created patient with the generated ID
153
+ created_patient = await patients_collection.find_one(
154
+ {"_id": result.inserted_id}
155
+ )
156
+
157
+ if not created_patient:
158
+ logger.error("Failed to retrieve created patient")
159
+ raise HTTPException(
160
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
161
+ detail="Failed to retrieve created patient"
162
+ )
163
+
164
+ created_patient["id"] = str(created_patient["_id"])
165
+ del created_patient["_id"]
166
+
167
+ logger.info(f"Successfully created patient {created_patient['fhir_id']}")
168
+ return created_patient
169
+
170
+ except Exception as e:
171
+ logger.error(f"Failed to create patient: {str(e)}")
172
+ raise HTTPException(
173
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
174
+ detail=f"Failed to create patient: {str(e)}"
175
+ )
176
+
177
+ @router.delete("/patients/{patient_id}", status_code=status.HTTP_204_NO_CONTENT)
178
+ async def delete_patient(
179
+ patient_id: str,
180
+ current_user: dict = Depends(get_current_user)
181
+ ):
182
+ """Delete a patient from the database"""
183
+ logger.info(f"Deleting patient {patient_id} by user {current_user.get('email')}")
184
+
185
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
186
+ logger.warning(f"Unauthorized delete attempt by {current_user.get('email')}")
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="Only administrators can delete patients"
190
+ )
191
+
192
+ try:
193
+ # Build the query based on whether patient_id is a valid ObjectId
194
+ query = {"fhir_id": patient_id}
195
+ if ObjectId.is_valid(patient_id):
196
+ query = {
197
+ "$or": [
198
+ {"_id": ObjectId(patient_id)},
199
+ {"fhir_id": patient_id}
200
+ ]
201
+ }
202
+
203
+ # Check if patient exists
204
+ patient = await patients_collection.find_one(query)
205
+
206
+ if not patient:
207
+ logger.warning(f"Patient not found for deletion: {patient_id}")
208
+ raise HTTPException(
209
+ status_code=status.HTTP_404_NOT_FOUND,
210
+ detail="Patient not found"
211
+ )
212
+
213
+ # Perform deletion
214
+ result = await patients_collection.delete_one(query)
215
+
216
+ if result.deleted_count == 0:
217
+ logger.error(f"Failed to delete patient {patient_id}")
218
+ raise HTTPException(
219
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
220
+ detail="Failed to delete patient"
221
+ )
222
+
223
+ logger.info(f"Successfully deleted patient {patient_id}")
224
+ return None
225
+
226
+ except HTTPException:
227
+ raise
228
+ except Exception as e:
229
+ logger.error(f"Failed to delete patient {patient_id}: {str(e)}")
230
+ raise HTTPException(
231
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
232
+ detail=f"Failed to delete patient: {str(e)}"
233
+ )
234
+
235
+ async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
236
+ logger.debug(f"Processing patient from file: {file_path}")
237
+ patient_data = {}
238
+ notes = []
239
+ conditions = []
240
+ medications = []
241
+ encounters = []
242
+
243
+ # Validate bundle structure
244
+ if not isinstance(bundle, dict) or 'entry' not in bundle:
245
+ logger.error(f"Invalid FHIR bundle structure in {file_path}")
246
+ return None
247
+
248
+ for entry in bundle.get('entry', []):
249
+ resource = entry.get('resource', {})
250
+ resource_type = resource.get('resourceType')
251
+
252
+ if not resource_type:
253
+ logger.warning(f"Skipping entry with missing resourceType in {file_path}")
254
+ continue
255
+
256
+ try:
257
+ if resource_type == 'Patient':
258
+ name = resource.get('name', [{}])[0]
259
+ address = resource.get('address', [{}])[0]
260
+
261
+ # Construct full name and remove numbers
262
+ raw_full_name = f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip()
263
+ clean_full_name = re.sub(r'\d+', '', raw_full_name).strip()
264
+
265
+ patient_data = {
266
+ 'fhir_id': resource.get('id'),
267
+ 'full_name': clean_full_name,
268
+ 'gender': resource.get('gender', 'unknown'),
269
+ 'date_of_birth': resource.get('birthDate', ''),
270
+ 'address': ' '.join(address.get('line', [''])),
271
+ 'city': address.get('city', ''),
272
+ 'state': address.get('state', ''),
273
+ 'postal_code': address.get('postalCode', ''),
274
+ 'country': address.get('country', ''),
275
+ 'marital_status': resource.get('maritalStatus', {}).get('text', ''),
276
+ 'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
277
+ 'source': 'synthea',
278
+ 'last_updated': datetime.utcnow().isoformat()
279
+ }
280
+
281
+ elif resource_type == 'Encounter':
282
+ encounter = {
283
+ 'id': resource.get('id'),
284
+ 'type': resource.get('type', [{}])[0].get('text', ''),
285
+ 'status': resource.get('status'),
286
+ 'period': resource.get('period', {}),
287
+ 'service_provider': resource.get('serviceProvider', {}).get('display', '')
288
+ }
289
+ encounters.append(encounter)
290
+
291
+ for note in resource.get('note', []):
292
+ if note.get('text'):
293
+ notes.append({
294
+ 'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
295
+ 'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
296
+ 'text': note.get('text'),
297
+ 'context': f"Encounter: {encounter.get('type')}",
298
+ 'author': 'System Generated'
299
+ })
300
+
301
+ elif resource_type == 'Condition':
302
+ conditions.append({
303
+ 'id': resource.get('id'),
304
+ 'code': resource.get('code', {}).get('text', ''),
305
+ 'status': resource.get('clinicalStatus', {}).get('text', ''),
306
+ 'onset_date': resource.get('onsetDateTime'),
307
+ 'recorded_date': resource.get('recordedDate'),
308
+ 'verification_status': resource.get('verificationStatus', {}).get('text', '')
309
+ })
310
+
311
+ elif resource_type == 'MedicationRequest':
312
+ medications.append({
313
+ 'id': resource.get('id'),
314
+ 'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
315
+ 'status': resource.get('status'),
316
+ 'prescribed_date': resource.get('authoredOn'),
317
+ 'requester': resource.get('requester', {}).get('display', ''),
318
+ 'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
319
+ })
320
+
321
+ except Exception as e:
322
+ logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
323
+ continue
324
+
325
+ if patient_data:
326
+ patient_data.update({
327
+ 'notes': notes,
328
+ 'conditions': conditions,
329
+ 'medications': medications,
330
+ 'encounters': encounters,
331
+ 'import_date': datetime.utcnow().isoformat()
332
+ })
333
+ logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
334
+ return patient_data
335
+ logger.warning(f"No valid patient data found in {file_path}")
336
+ return None
337
+
338
+ @router.post("/import", status_code=status.HTTP_201_CREATED)
339
+ async def import_patients(
340
+ limit: int = Query(100, ge=1, le=1000),
341
+ current_user: dict = Depends(get_current_user)
342
+ ):
343
+ request_id = str(uuid.uuid4())
344
+ logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
345
+ start_time = time.time()
346
+
347
+ if current_user.get('role') not in ['admin', 'doctor']:
348
+ logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
349
+ raise HTTPException(
350
+ status_code=status.HTTP_403_FORBIDDEN,
351
+ detail="Only administrators and doctors can import data"
352
+ )
353
+
354
+ try:
355
+ await create_indexes()
356
+
357
+ if not SYNTHEA_DATA_DIR.exists():
358
+ logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
359
+ raise HTTPException(
360
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
361
+ detail="Data directory not found"
362
+ )
363
+
364
+ # Filter out non-patient files
365
+ files = [
366
+ f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
367
+ if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
368
+ ]
369
+ if not files:
370
+ logger.warning("No valid patient JSON files found in synthea data directory")
371
+ return {
372
+ "status": "success",
373
+ "message": "No patient data files found",
374
+ "imported": 0,
375
+ "request_id": request_id
376
+ }
377
+
378
+ operations = []
379
+ imported = 0
380
+ errors = []
381
+
382
+ for file_path in files[:limit]:
383
+ try:
384
+ logger.debug(f"Processing file: {file_path}")
385
+
386
+ # Check file accessibility
387
+ if not os.path.exists(file_path):
388
+ logger.error(f"File not found: {file_path}")
389
+ errors.append(f"File not found: {file_path}")
390
+ continue
391
+
392
+ # Check file size
393
+ file_size = os.path.getsize(file_path)
394
+ if file_size == 0:
395
+ logger.warning(f"Empty file: {file_path}")
396
+ errors.append(f"Empty file: {file_path}")
397
+ continue
398
+
399
+ with open(file_path, 'r', encoding='utf-8') as f:
400
+ try:
401
+ bundle = json.load(f)
402
+ except json.JSONDecodeError as je:
403
+ logger.error(f"Invalid JSON in {file_path}: {str(je)}")
404
+ errors.append(f"Invalid JSON in {file_path}: {str(je)}")
405
+ continue
406
+
407
+ patient = await process_synthea_patient(bundle, file_path)
408
+ if patient:
409
+ if not patient.get('fhir_id'):
410
+ logger.warning(f"Missing FHIR ID in patient data from {file_path}")
411
+ errors.append(f"Missing FHIR ID in {file_path}")
412
+ continue
413
+
414
+ operations.append(UpdateOne(
415
+ {"fhir_id": patient['fhir_id']},
416
+ {"$setOnInsert": patient},
417
+ upsert=True
418
+ ))
419
+ imported += 1
420
+ else:
421
+ logger.warning(f"No valid patient data in {file_path}")
422
+ errors.append(f"No valid patient data in {file_path}")
423
+
424
+ except Exception as e:
425
+ logger.error(f"Error processing {file_path}: {str(e)}")
426
+ errors.append(f"Error in {file_path}: {str(e)}")
427
+ continue
428
+
429
+ response = {
430
+ "status": "success",
431
+ "imported": imported,
432
+ "errors": errors,
433
+ "request_id": request_id,
434
+ "duration_seconds": time.time() - start_time
435
+ }
436
+
437
+ if operations:
438
+ try:
439
+ result = await patients_collection.bulk_write(operations, ordered=False)
440
+ response.update({
441
+ "upserted": result.upserted_count,
442
+ "existing": len(operations) - result.upserted_count
443
+ })
444
+ logger.info(f"Import request {request_id} completed: {imported} patients processed, "
445
+ f"{result.upserted_count} upserted, {len(errors)} errors")
446
+ except BulkWriteError as bwe:
447
+ logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
448
+ response.update({
449
+ "upserted": bwe.details.get('nUpserted', 0),
450
+ "existing": len(operations) - bwe.details.get('nUpserted', 0),
451
+ "write_errors": [
452
+ f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
453
+ ]
454
+ })
455
+ logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
456
+ f"{response['upserted']} upserted, {len(errors)} errors")
457
+ except Exception as e:
458
+ logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
459
+ raise HTTPException(
460
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
461
+ detail=f"Database operation failed: {str(e)}"
462
+ )
463
+ else:
464
+ logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
465
+ response["message"] = "No new patients found to import"
466
+
467
+ return response
468
+
469
+ except HTTPException:
470
+ raise
471
+ except Exception as e:
472
+ logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
473
+ raise HTTPException(
474
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
475
+ detail=f"Import failed: {str(e)}"
476
+ )
477
+
478
+ @router.post("/patients/import-ehr", status_code=status.HTTP_201_CREATED)
479
+ async def import_ehr_patients(
480
+ ehr_data: List[dict],
481
+ ehr_system: str = Query(..., description="Name of the EHR system"),
482
+ current_user: dict = Depends(get_current_user),
483
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
484
+ ):
485
+ """Import patients from external EHR system"""
486
+ logger.info(f"Importing {len(ehr_data)} patients from EHR system: {ehr_system}")
487
+
488
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
489
+ logger.warning(f"Unauthorized EHR import attempt by {current_user.get('email')}")
490
+ raise HTTPException(
491
+ status_code=status.HTTP_403_FORBIDDEN,
492
+ detail="Only administrators and doctors can import EHR patients"
493
+ )
494
+
495
+ try:
496
+ imported_patients = []
497
+ skipped_patients = []
498
+
499
+ for patient_data in ehr_data:
500
+ # Check if patient already exists by multiple criteria
501
+ existing_patient = await patients_collection.find_one({
502
+ "$or": [
503
+ {"ehr_id": patient_data.get("ehr_id"), "ehr_system": ehr_system},
504
+ {"full_name": patient_data.get("full_name"), "date_of_birth": patient_data.get("date_of_birth")},
505
+ {"national_id": patient_data.get("national_id")} if patient_data.get("national_id") else {}
506
+ ]
507
+ })
508
+
509
+ if existing_patient:
510
+ skipped_patients.append(patient_data.get("full_name", "Unknown"))
511
+ logger.info(f"Patient {patient_data.get('full_name', 'Unknown')} already exists, skipping...")
512
+ continue
513
+
514
+ # Prepare patient document for EHR import
515
+ patient_doc = {
516
+ "full_name": patient_data.get("full_name"),
517
+ "date_of_birth": patient_data.get("date_of_birth"),
518
+ "gender": patient_data.get("gender"),
519
+ "address": patient_data.get("address"),
520
+ "national_id": patient_data.get("national_id"),
521
+ "blood_type": patient_data.get("blood_type"),
522
+ "allergies": patient_data.get("allergies", []),
523
+ "chronic_conditions": patient_data.get("chronic_conditions", []),
524
+ "medications": patient_data.get("medications", []),
525
+ "emergency_contact_name": patient_data.get("emergency_contact_name"),
526
+ "emergency_contact_phone": patient_data.get("emergency_contact_phone"),
527
+ "insurance_provider": patient_data.get("insurance_provider"),
528
+ "insurance_policy_number": patient_data.get("insurance_policy_number"),
529
+ "contact": patient_data.get("contact"),
530
+ "source": "ehr",
531
+ "ehr_id": patient_data.get("ehr_id"),
532
+ "ehr_system": ehr_system,
533
+ "status": "active",
534
+ "registration_date": datetime.now(),
535
+ "created_by": current_user.get('email'),
536
+ "created_at": datetime.now(),
537
+ "updated_at": datetime.now()
538
+ }
539
+
540
+ # Insert patient
541
+ result = await patients_collection.insert_one(patient_doc)
542
+ imported_patients.append(patient_data.get("full_name", "Unknown"))
543
+
544
+ logger.info(f"Successfully imported {len(imported_patients)} patients, skipped {len(skipped_patients)}")
545
+
546
+ return {
547
+ "message": f"Successfully imported {len(imported_patients)} patients from {ehr_system}",
548
+ "imported_count": len(imported_patients),
549
+ "skipped_count": len(skipped_patients),
550
+ "imported_patients": imported_patients,
551
+ "skipped_patients": skipped_patients
552
+ }
553
+
554
+ except Exception as e:
555
+ logger.error(f"Error importing EHR patients: {str(e)}")
556
+ raise HTTPException(
557
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
558
+ detail=f"Error importing EHR patients: {str(e)}"
559
+ )
560
+
561
+ @router.get("/patients/sources", response_model=List[dict])
562
+ async def get_patient_sources(
563
+ current_user: dict = Depends(get_current_user)
564
+ ):
565
+ """Get available patient sources and their counts"""
566
+ try:
567
+ # Get counts for each source
568
+ source_counts = await patients_collection.aggregate([
569
+ {
570
+ "$group": {
571
+ "_id": "$source",
572
+ "count": {"$sum": 1}
573
+ }
574
+ }
575
+ ]).to_list(length=None)
576
+
577
+ # Format the response
578
+ sources = []
579
+ for source_count in source_counts:
580
+ source_name = source_count["_id"] or "unknown"
581
+ sources.append({
582
+ "source": source_name,
583
+ "count": source_count["count"],
584
+ "label": source_name.replace("_", " ").title()
585
+ })
586
+
587
+ return sources
588
+
589
+ except Exception as e:
590
+ logger.error(f"Error getting patient sources: {str(e)}")
591
+ raise HTTPException(
592
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
593
+ detail=f"Error getting patient sources: {str(e)}"
594
+ )
595
+
596
+ @router.get("/patients", response_model=PatientListResponse)
597
+ async def get_patients(
598
+ page: int = Query(1, ge=1),
599
+ limit: int = Query(20, ge=1, le=100),
600
+ search: Optional[str] = Query(None),
601
+ source: Optional[str] = Query(None), # Filter by patient source
602
+ patient_status: Optional[str] = Query(None), # Filter by patient status
603
+ doctor_id: Optional[str] = Query(None), # Filter by assigned doctor
604
+ current_user: dict = Depends(get_current_user),
605
+ db_client: AsyncIOMotorClient = Depends(lambda: db)
606
+ ):
607
+ """Get patients with filtering options"""
608
+ skip = (page - 1) * limit
609
+ user_id = current_user["_id"]
610
+
611
+ # Debug logging
612
+ logger.info(f"🔍 Getting patients for user: {current_user.get('email')} with roles: {current_user.get('roles', [])}")
613
+
614
+ # Build filter query
615
+ filter_query = {}
616
+
617
+ # Role-based access - apply this first
618
+ if 'admin' not in current_user.get('roles', []):
619
+ if 'doctor' in current_user.get('roles', []):
620
+ # Doctors can see all patients for now (temporarily simplified)
621
+ logger.info("👨‍⚕️ Doctor access - no restrictions applied")
622
+ pass # No restrictions for doctors
623
+ else:
624
+ # Patients can only see their own record
625
+ logger.info(f"👤 Patient access - restricting to own record: {user_id}")
626
+ filter_query["_id"] = ObjectId(user_id)
627
+
628
+ # Build additional filters
629
+ additional_filters = {}
630
+
631
+ # Add search filter
632
+ if search:
633
+ additional_filters["$or"] = [
634
+ {"full_name": {"$regex": search, "$options": "i"}},
635
+ {"national_id": {"$regex": search, "$options": "i"}},
636
+ {"ehr_id": {"$regex": search, "$options": "i"}}
637
+ ]
638
+
639
+ # Add source filter
640
+ if source:
641
+ additional_filters["source"] = source
642
+
643
+ # Add status filter
644
+ if patient_status:
645
+ additional_filters["status"] = patient_status
646
+
647
+ # Add doctor assignment filter
648
+ if doctor_id:
649
+ additional_filters["assigned_doctor_id"] = ObjectId(doctor_id)
650
+
651
+ # Combine filters
652
+ if additional_filters:
653
+ if filter_query.get("$or"):
654
+ # If we have role-based $or, we need to combine with additional filters
655
+ # Create a new $and condition
656
+ filter_query = {
657
+ "$and": [
658
+ filter_query,
659
+ additional_filters
660
+ ]
661
+ }
662
+ else:
663
+ # No role-based restrictions, just use additional filters
664
+ filter_query.update(additional_filters)
665
+
666
+ logger.info(f"🔍 Final filter query: {filter_query}")
667
+
668
+ try:
669
+ # Get total count
670
+ total = await patients_collection.count_documents(filter_query)
671
+ logger.info(f"📊 Total patients matching filter: {total}")
672
+
673
+ # Get patients with pagination
674
+ patients_cursor = patients_collection.find(filter_query).skip(skip).limit(limit)
675
+ patients = await patients_cursor.to_list(length=limit)
676
+ logger.info(f"📋 Retrieved {len(patients)} patients")
677
+
678
+ # Process patients to include doctor names and format dates
679
+ processed_patients = []
680
+ for patient in patients:
681
+ # Get assigned doctor name if exists
682
+ assigned_doctor_name = None
683
+ if patient.get("assigned_doctor_id"):
684
+ doctor = await db_client.users.find_one({"_id": patient["assigned_doctor_id"]})
685
+ if doctor:
686
+ assigned_doctor_name = doctor.get("full_name")
687
+
688
+ # Convert ObjectId to string
689
+ patient["id"] = str(patient["_id"])
690
+ del patient["_id"]
691
+
692
+ # Add assigned doctor name
693
+ patient["assigned_doctor_name"] = assigned_doctor_name
694
+
695
+ processed_patients.append(patient)
696
+
697
+ logger.info(f"✅ Returning {len(processed_patients)} processed patients")
698
+
699
+ return PatientListResponse(
700
+ patients=processed_patients,
701
+ total=total,
702
+ page=page,
703
+ limit=limit,
704
+ source_filter=source,
705
+ status_filter=patient_status
706
+ )
707
+
708
+ except Exception as e:
709
+ logger.error(f"❌ Error fetching patients: {str(e)}")
710
+ raise HTTPException(
711
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
712
+ detail=f"Error fetching patients: {str(e)}"
713
+ )
714
+
715
+ @router.get("/patients/{patient_id}", response_model=dict)
716
+ async def get_patient(patient_id: str):
717
+ logger.info(f"Retrieving patient: {patient_id}")
718
+ try:
719
+ patient = await patients_collection.find_one({
720
+ "$or": [
721
+ {"_id": ObjectId(patient_id)},
722
+ {"fhir_id": patient_id}
723
+ ]
724
+ })
725
+
726
+ if not patient:
727
+ logger.warning(f"Patient not found: {patient_id}")
728
+ raise HTTPException(
729
+ status_code=status.HTTP_404_NOT_FOUND,
730
+ detail="Patient not found"
731
+ )
732
+
733
+ response = {
734
+ "demographics": {
735
+ "id": str(patient["_id"]),
736
+ "fhir_id": patient.get("fhir_id"),
737
+ "full_name": patient.get("full_name"),
738
+ "gender": patient.get("gender"),
739
+ "date_of_birth": patient.get("date_of_birth"),
740
+ "age": calculate_age(patient.get("date_of_birth")),
741
+ "address": {
742
+ "line": patient.get("address"),
743
+ "city": patient.get("city"),
744
+ "state": patient.get("state"),
745
+ "postal_code": patient.get("postal_code"),
746
+ "country": patient.get("country")
747
+ },
748
+ "marital_status": patient.get("marital_status"),
749
+ "language": patient.get("language")
750
+ },
751
+ "clinical_data": {
752
+ "notes": patient.get("notes", []),
753
+ "conditions": patient.get("conditions", []),
754
+ "medications": patient.get("medications", []),
755
+ "encounters": patient.get("encounters", [])
756
+ },
757
+ "metadata": {
758
+ "source": patient.get("source"),
759
+ "import_date": patient.get("import_date"),
760
+ "last_updated": patient.get("last_updated")
761
+ }
762
+ }
763
+
764
+ logger.info(f"Successfully retrieved patient: {patient_id}")
765
+ return response
766
+
767
+ except ValueError as ve:
768
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
769
+ raise HTTPException(
770
+ status_code=status.HTTP_400_BAD_REQUEST,
771
+ detail="Invalid patient ID format"
772
+ )
773
+ except Exception as e:
774
+ logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
775
+ raise HTTPException(
776
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
777
+ detail=f"Failed to retrieve patient: {str(e)}"
778
+ )
779
+
780
+ @router.post("/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
781
+ async def add_note(
782
+ patient_id: str,
783
+ note: Note,
784
+ current_user: dict = Depends(get_current_user)
785
+ ):
786
+ logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
787
+ if current_user.get('role') not in ['doctor', 'admin']:
788
+ logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
789
+ raise HTTPException(
790
+ status_code=status.HTTP_403_FORBIDDEN,
791
+ detail="Only clinicians can add notes"
792
+ )
793
+
794
+ try:
795
+ note_data = note.dict()
796
+ note_data.update({
797
+ "author": current_user.get('full_name', 'System'),
798
+ "timestamp": datetime.utcnow().isoformat()
799
+ })
800
+
801
+ result = await patients_collection.update_one(
802
+ {"$or": [
803
+ {"_id": ObjectId(patient_id)},
804
+ {"fhir_id": patient_id}
805
+ ]},
806
+ {
807
+ "$push": {"notes": note_data},
808
+ "$set": {"last_updated": datetime.utcnow().isoformat()}
809
+ }
810
+ )
811
+
812
+ if result.modified_count == 0:
813
+ logger.warning(f"Patient not found for note addition: {patient_id}")
814
+ raise HTTPException(
815
+ status_code=status.HTTP_404_NOT_FOUND,
816
+ detail="Patient not found"
817
+ )
818
+
819
+ logger.info(f"Note added successfully for patient {patient_id}")
820
+ return {"status": "success", "message": "Note added"}
821
+
822
+ except ValueError as ve:
823
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
824
+ raise HTTPException(
825
+ status_code=status.HTTP_400_BAD_REQUEST,
826
+ detail="Invalid patient ID format"
827
+ )
828
+ except Exception as e:
829
+ logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
830
+ raise HTTPException(
831
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
832
+ detail=f"Failed to add note: {str(e)}"
833
+ )
834
+
835
+ @router.put("/patients/{patient_id}", status_code=status.HTTP_200_OK)
836
+ async def update_patient(
837
+ patient_id: str,
838
+ update_data: PatientUpdate,
839
+ current_user: dict = Depends(get_current_user)
840
+ ):
841
+ """Update a patient's record in the database"""
842
+ logger.info(f"Updating patient {patient_id} by user {current_user.get('email')}")
843
+
844
+ if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
845
+ logger.warning(f"Unauthorized update attempt by {current_user.get('email')}")
846
+ raise HTTPException(
847
+ status_code=status.HTTP_403_FORBIDDEN,
848
+ detail="Only administrators and doctors can update patients"
849
+ )
850
+
851
+ try:
852
+ # Build the query based on whether patient_id is a valid ObjectId
853
+ query = {"fhir_id": patient_id}
854
+ if ObjectId.is_valid(patient_id):
855
+ query = {
856
+ "$or": [
857
+ {"_id": ObjectId(patient_id)},
858
+ {"fhir_id": patient_id}
859
+ ]
860
+ }
861
+
862
+ # Check if patient exists
863
+ patient = await patients_collection.find_one(query)
864
+ if not patient:
865
+ logger.warning(f"Patient not found for update: {patient_id}")
866
+ raise HTTPException(
867
+ status_code=status.HTTP_404_NOT_FOUND,
868
+ detail="Patient not found"
869
+ )
870
+
871
+ # Prepare update operations
872
+ update_ops = {"$set": {"last_updated": datetime.utcnow().isoformat()}}
873
+
874
+ # Handle demographic updates
875
+ demographics = {
876
+ "full_name": update_data.full_name,
877
+ "gender": update_data.gender,
878
+ "date_of_birth": update_data.date_of_birth,
879
+ "address": update_data.address,
880
+ "city": update_data.city,
881
+ "state": update_data.state,
882
+ "postal_code": update_data.postal_code,
883
+ "country": update_data.country,
884
+ "marital_status": update_data.marital_status,
885
+ "language": update_data.language
886
+ }
887
+ for key, value in demographics.items():
888
+ if value is not None:
889
+ update_ops["$set"][key] = value
890
+
891
+ # Handle array updates (conditions, medications, encounters, notes)
892
+ array_fields = {
893
+ "conditions": update_data.conditions,
894
+ "medications": update_data.medications,
895
+ "encounters": update_data.encounters,
896
+ "notes": update_data.notes
897
+ }
898
+
899
+ for field, items in array_fields.items():
900
+ if items is not None:
901
+ # Fetch existing items
902
+ existing_items = patient.get(field, [])
903
+ updated_items = []
904
+
905
+ for item in items:
906
+ item_dict = item.dict(exclude_unset=True)
907
+ if not item_dict:
908
+ continue
909
+
910
+ # Generate ID for new items
911
+ if not item_dict.get("id"):
912
+ item_dict["id"] = str(uuid.uuid4())
913
+
914
+ # Validate required fields
915
+ if field == "conditions" and not item_dict.get("code"):
916
+ raise HTTPException(
917
+ status_code=status.HTTP_400_BAD_REQUEST,
918
+ detail=f"Condition code is required for {field}"
919
+ )
920
+ if field == "medications" and not item_dict.get("name"):
921
+ raise HTTPException(
922
+ status_code=status.HTTP_400_BAD_REQUEST,
923
+ detail=f"Medication name is required for {field}"
924
+ )
925
+ if field == "encounters" and not item_dict.get("type"):
926
+ raise HTTPException(
927
+ status_code=status.HTTP_400_BAD_REQUEST,
928
+ detail=f"Encounter type is required for {field}"
929
+ )
930
+ if field == "notes" and not item_dict.get("content"):
931
+ raise HTTPException(
932
+ status_code=status.HTTP_400_BAD_REQUEST,
933
+ detail=f"Note content is required for {field}"
934
+ )
935
+
936
+ updated_items.append(item_dict)
937
+
938
+ # Replace the entire array
939
+ update_ops["$set"][field] = updated_items
940
+
941
+ # Perform the update
942
+ result = await patients_collection.update_one(query, update_ops)
943
+
944
+ if result.modified_count == 0 and result.matched_count == 0:
945
+ logger.warning(f"Patient not found for update: {patient_id}")
946
+ raise HTTPException(
947
+ status_code=status.HTTP_404_NOT_FOUND,
948
+ detail="Patient not found"
949
+ )
950
+
951
+ # Retrieve and return the updated patient
952
+ updated_patient = await patients_collection.find_one(query)
953
+ if not updated_patient:
954
+ logger.error(f"Failed to retrieve updated patient: {patient_id}")
955
+ raise HTTPException(
956
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
957
+ detail="Failed to retrieve updated patient"
958
+ )
959
+
960
+ response = {
961
+ "id": str(updated_patient["_id"]),
962
+ "fhir_id": updated_patient.get("fhir_id"),
963
+ "full_name": updated_patient.get("full_name"),
964
+ "gender": updated_patient.get("gender"),
965
+ "date_of_birth": updated_patient.get("date_of_birth"),
966
+ "address": updated_patient.get("address"),
967
+ "city": updated_patient.get("city"),
968
+ "state": updated_patient.get("state"),
969
+ "postal_code": updated_patient.get("postal_code"),
970
+ "country": updated_patient.get("country"),
971
+ "marital_status": updated_patient.get("marital_status"),
972
+ "language": updated_patient.get("language"),
973
+ "conditions": updated_patient.get("conditions", []),
974
+ "medications": updated_patient.get("medications", []),
975
+ "encounters": updated_patient.get("encounters", []),
976
+ "notes": updated_patient.get("notes", []),
977
+ "source": updated_patient.get("source"),
978
+ "import_date": updated_patient.get("import_date"),
979
+ "last_updated": updated_patient.get("last_updated")
980
+ }
981
+
982
+ logger.info(f"Successfully updated patient {patient_id}")
983
+ return response
984
+
985
+ except ValueError as ve:
986
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
987
+ raise HTTPException(
988
+ status_code=status.HTTP_400_BAD_REQUEST,
989
+ detail="Invalid patient ID format"
990
+ )
991
+ except HTTPException:
992
+ raise
993
+ except Exception as e:
994
+ logger.error(f"Failed to update patient {patient_id}: {str(e)}")
995
+ raise HTTPException(
996
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
997
+ detail=f"Failed to update patient: {str(e)}"
998
+ )
999
+
1000
+ # FHIR Integration Endpoints
1001
+ @router.post("/patients/import-hapi-fhir", status_code=status.HTTP_201_CREATED)
1002
+ async def import_hapi_patients(
1003
+ limit: int = Query(20, ge=1, le=100, description="Number of patients to import"),
1004
+ current_user: dict = Depends(get_current_user)
1005
+ ):
1006
+ """
1007
+ Import patients from HAPI FHIR Test Server
1008
+ """
1009
+ try:
1010
+ service = HAPIFHIRIntegrationService()
1011
+ result = await service.import_patients_from_hapi(limit=limit)
1012
+
1013
+ # Create detailed message
1014
+ message_parts = []
1015
+ if result["imported_count"] > 0:
1016
+ message_parts.append(f"Successfully imported {result['imported_count']} patients")
1017
+ if result["skipped_count"] > 0:
1018
+ message_parts.append(f"Skipped {result['skipped_count']} duplicate patients")
1019
+ if result["errors"]:
1020
+ message_parts.append(f"Encountered {len(result['errors'])} errors")
1021
+
1022
+ message = ". ".join(message_parts) + " from HAPI FHIR"
1023
+
1024
+ return {
1025
+ "message": message,
1026
+ "imported_count": result["imported_count"],
1027
+ "skipped_count": result["skipped_count"],
1028
+ "total_found": result["total_found"],
1029
+ "imported_patients": result["imported_patients"],
1030
+ "skipped_patients": result["skipped_patients"],
1031
+ "errors": result["errors"],
1032
+ "source": "hapi_fhir"
1033
+ }
1034
+
1035
+ except Exception as e:
1036
+ logger.error(f"Error importing HAPI FHIR patients: {str(e)}")
1037
+ raise HTTPException(
1038
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1039
+ detail=f"Failed to import patients from HAPI FHIR: {str(e)}"
1040
+ )
1041
+
1042
+ @router.post("/patients/sync-patient/{patient_id}")
1043
+ async def sync_patient_data(
1044
+ patient_id: str,
1045
+ current_user: dict = Depends(get_current_user)
1046
+ ):
1047
+ """
1048
+ Sync a specific patient's data from HAPI FHIR
1049
+ """
1050
+ try:
1051
+ service = HAPIFHIRIntegrationService()
1052
+ success = await service.sync_patient_data(patient_id)
1053
+
1054
+ if success:
1055
+ return {
1056
+ "message": f"Successfully synced patient {patient_id} from HAPI FHIR",
1057
+ "patient_id": patient_id,
1058
+ "success": True
1059
+ }
1060
+ else:
1061
+ raise HTTPException(
1062
+ status_code=status.HTTP_404_NOT_FOUND,
1063
+ detail=f"Patient {patient_id} not found in HAPI FHIR or sync failed"
1064
+ )
1065
+
1066
+ except HTTPException:
1067
+ raise
1068
+ except Exception as e:
1069
+ logger.error(f"Error syncing patient {patient_id}: {str(e)}")
1070
+ raise HTTPException(
1071
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1072
+ detail=f"Failed to sync patient: {str(e)}"
1073
+ )
1074
+
1075
+ @router.get("/patients/hapi-fhir/patients")
1076
+ async def get_hapi_patients(
1077
+ limit: int = Query(50, ge=1, le=200, description="Number of patients to fetch"),
1078
+ current_user: dict = Depends(get_current_user)
1079
+ ):
1080
+ """
1081
+ Get patients from HAPI FHIR without importing them
1082
+ """
1083
+ try:
1084
+ service = HAPIFHIRIntegrationService()
1085
+ patients = await service.get_hapi_patients(limit=limit)
1086
+
1087
+ return {
1088
+ "patients": patients,
1089
+ "count": len(patients),
1090
+ "source": "hapi_fhir"
1091
+ }
1092
+
1093
+ except Exception as e:
1094
+ logger.error(f"Error fetching HAPI FHIR patients: {str(e)}")
1095
+ raise HTTPException(
1096
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1097
+ detail=f"Failed to fetch patients from HAPI FHIR: {str(e)}"
1098
+ )
1099
+
1100
+ @router.get("/patients/hapi-fhir/patients/{patient_id}")
1101
+ async def get_hapi_patient_details(
1102
+ patient_id: str,
1103
+ current_user: dict = Depends(get_current_user)
1104
+ ):
1105
+ """
1106
+ Get detailed information for a specific HAPI FHIR patient
1107
+ """
1108
+ try:
1109
+ service = HAPIFHIRIntegrationService()
1110
+ patient_details = await service.get_hapi_patient_details(patient_id)
1111
+
1112
+ if not patient_details:
1113
+ raise HTTPException(
1114
+ status_code=status.HTTP_404_NOT_FOUND,
1115
+ detail=f"Patient {patient_id} not found in HAPI FHIR"
1116
+ )
1117
+
1118
+ return patient_details
1119
+
1120
+ except HTTPException:
1121
+ raise
1122
+ except Exception as e:
1123
+ logger.error(f"Error fetching HAPI FHIR patient details: {str(e)}")
1124
+ raise HTTPException(
1125
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1126
+ detail=f"Failed to fetch patient details from HAPI FHIR: {str(e)}"
1127
+ )
1128
+
1129
+ @router.get("/patients/hapi-fhir/statistics")
1130
+ async def get_hapi_statistics(
1131
+ current_user: dict = Depends(get_current_user)
1132
+ ):
1133
+ """
1134
+ Get statistics about HAPI FHIR imported patients
1135
+ """
1136
+ try:
1137
+ service = HAPIFHIRIntegrationService()
1138
+ stats = await service.get_patient_statistics()
1139
+
1140
+ return {
1141
+ "statistics": stats,
1142
+ "source": "hapi_fhir"
1143
+ }
1144
+
1145
+ except Exception as e:
1146
+ logger.error(f"Error getting HAPI FHIR statistics: {str(e)}")
1147
+ raise HTTPException(
1148
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1149
+ detail=f"Failed to get HAPI FHIR statistics: {str(e)}"
1150
+ )
1151
+
1152
+ # Export the router as 'patients' for api.__init__.py
1153
+ patients = router
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
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")
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
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()
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'