feat(api): production security, cors hardening, latency optimizations, postman artifacts & frontend documentation
This commit is contained in:
@@ -7,6 +7,9 @@ from app.database.database import get_db
|
||||
from app.schemas.documents import DocumentExtractionResponse
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from app.core.security import get_auth_context, AuthContext, check_rate_limit
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/documents",
|
||||
tags=["Medical Documents"],
|
||||
@@ -20,8 +23,13 @@ router = APIRouter(
|
||||
async def upload_medical_document(
|
||||
session_id: str,
|
||||
file: UploadFile = File(...),
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=10, window_sec=60)
|
||||
|
||||
onboarding_service = OnboardingService()
|
||||
gemini_client = GeminiClient()
|
||||
|
||||
@@ -29,9 +37,28 @@ async def upload_medical_document(
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Onboarding session not found")
|
||||
|
||||
state = onboarding_service.get_state(db, session_id)
|
||||
file_bytes = await file.read()
|
||||
# 1b. Verify Session Ownership (IDOR Prevention)
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden: You do not have authorization to upload documents to this session.",
|
||||
)
|
||||
|
||||
# Enforce MIME type and File Size Limits (10MB MAX)
|
||||
allowed_mimes = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
|
||||
mime_type = file.content_type or "application/pdf"
|
||||
if mime_type not in allowed_mimes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported document format: {mime_type}. Allowed formats: JPEG, PNG, WEBP, PDF.",
|
||||
)
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 10 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="File size exceeds maximum allowed limit of 10MB.",
|
||||
)
|
||||
|
||||
patient_context = {
|
||||
"id": session.patient_id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -36,6 +37,10 @@ from app.services.result_extraction_service import (
|
||||
from app.services.voice_service import VoiceService
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.security import get_auth_context, AuthContext, check_rate_limit
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/onboarding",
|
||||
tags=["Patient Onboarding"],
|
||||
@@ -48,14 +53,22 @@ router = APIRouter(
|
||||
)
|
||||
async def start_onboarding(
|
||||
request: StartOnboardingRequest,
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
|
||||
|
||||
onboarding_service = OnboardingService()
|
||||
session_id = f"ONB-{uuid4().hex[:8].upper()}"
|
||||
|
||||
# Use authenticated patient identity
|
||||
patient_id = request.patient.id or auth.patient_id
|
||||
|
||||
session = OnboardingSession(
|
||||
session_id=session_id,
|
||||
patient_id=request.patient.id,
|
||||
patient_id=patient_id,
|
||||
patient_name=request.patient.name,
|
||||
age=request.patient.age,
|
||||
gender=request.patient.gender,
|
||||
@@ -108,16 +121,24 @@ async def start_onboarding(
|
||||
async def send_message(
|
||||
session_id: str,
|
||||
request: SendMessageRequest,
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
onboarding_service = OnboardingService()
|
||||
ai_service = AIOnboardingService()
|
||||
|
||||
# 1. Get session
|
||||
t_sess_start = time.perf_counter()
|
||||
session = onboarding_service.get_session(
|
||||
db,
|
||||
session_id,
|
||||
)
|
||||
t_sess_end = time.perf_counter()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
@@ -125,6 +146,13 @@ async def send_message(
|
||||
detail="Onboarding session not found",
|
||||
)
|
||||
|
||||
# 1b. Verify Session Ownership (IDOR Prevention)
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden: You do not have authorization to access or modify this patient onboarding session.",
|
||||
)
|
||||
|
||||
# 2. Check session
|
||||
if session.status != "in_progress":
|
||||
raise HTTPException(
|
||||
@@ -142,12 +170,15 @@ async def send_message(
|
||||
)
|
||||
|
||||
# 4. Get state
|
||||
t_state_start = time.perf_counter()
|
||||
state = onboarding_service.get_state(
|
||||
db,
|
||||
session_id,
|
||||
)
|
||||
t_state_end = time.perf_counter()
|
||||
|
||||
# 5. Get conversation history
|
||||
t_hist_start = time.perf_counter()
|
||||
messages = (
|
||||
db.query(OnboardingMessage)
|
||||
.filter(
|
||||
@@ -167,14 +198,17 @@ async def send_message(
|
||||
}
|
||||
for message in messages
|
||||
]
|
||||
t_hist_end = time.perf_counter()
|
||||
|
||||
# 6. Send to AI
|
||||
t_ai_start = time.perf_counter()
|
||||
ai_response = await ai_service.process_message(
|
||||
session=session,
|
||||
state=state,
|
||||
conversation_history=conversation_history,
|
||||
user_message=request.message,
|
||||
)
|
||||
t_ai_end = time.perf_counter()
|
||||
|
||||
# 7. Update state
|
||||
current_state = json.loads(
|
||||
@@ -205,7 +239,9 @@ async def send_message(
|
||||
if ai_response.get("completed"):
|
||||
session.status = "completed"
|
||||
|
||||
t_db_start = time.perf_counter()
|
||||
db.commit()
|
||||
t_db_end = time.perf_counter()
|
||||
|
||||
# 10. Save AI message
|
||||
onboarding_service.save_message(
|
||||
@@ -216,12 +252,20 @@ async def send_message(
|
||||
stage=session.stage,
|
||||
)
|
||||
|
||||
t_total_end = time.perf_counter()
|
||||
total_ms = round((t_total_end - t0) * 1000, 2)
|
||||
total_sec = round(t_total_end - t0, 3)
|
||||
|
||||
print(f"[PERF PROFILE - send_message] SESSION_LOOKUP: {(t_sess_end - t_sess_start)*1000:.2f}ms | STATE_LOAD: {(t_state_end - t_state_start)*1000:.2f}ms | HIST_LOAD: {(t_hist_end - t_hist_start)*1000:.2f}ms | AI_PROCESS: {(t_ai_end - t_ai_start)*1000:.2f}ms | DB_COMMIT: {(t_db_end - t_db_start)*1000:.2f}ms | TOTAL: {total_ms}ms")
|
||||
|
||||
return SendMessageResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
stage=session.stage,
|
||||
message=ai_response["reply"],
|
||||
status=session.status,
|
||||
response_time_ms=total_ms,
|
||||
response_time_sec=total_sec,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,8 +276,10 @@ async def send_message(
|
||||
async def complete_onboarding(
|
||||
session_id: str,
|
||||
request: CompleteOnboardingRequest,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
start_time = time.time()
|
||||
onboarding_service = OnboardingService()
|
||||
completion_service = CompletionService()
|
||||
result_service = ResultExtractionService()
|
||||
@@ -251,6 +297,13 @@ async def complete_onboarding(
|
||||
detail="Onboarding session not found",
|
||||
)
|
||||
|
||||
# 1b. Verify Session Ownership (IDOR Prevention)
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden: You do not have authorization to complete this onboarding session.",
|
||||
)
|
||||
|
||||
# 2. Load state
|
||||
state = onboarding_service.get_state(
|
||||
db,
|
||||
@@ -297,19 +350,35 @@ async def complete_onboarding(
|
||||
# 7. Patient confirmed -> submit to existing patient / hospital API
|
||||
submission = await submission_service.submit(result)
|
||||
|
||||
if submission.get("success"):
|
||||
# 8. Successful submission -> clean up temporary SQLite session
|
||||
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
|
||||
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
if isinstance(result, dict):
|
||||
result["onboarding_completed"] = True
|
||||
|
||||
if session.status == "completed":
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="submitted_and_cleaned",
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
|
||||
message="Onboarding session is already completed.",
|
||||
response_time_ms=round((time.time() - start_time) * 1000, 2),
|
||||
response_time_sec=round(time.time() - start_time, 3),
|
||||
)
|
||||
|
||||
if submission.get("success"):
|
||||
# Mark session completed in DB instead of hard-deleting
|
||||
session.status = "completed"
|
||||
session.stage = "confirmation"
|
||||
db.commit()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Onboarding successfully completed and saved to patient record.",
|
||||
response_time_ms=round(elapsed * 1000, 2),
|
||||
response_time_sec=round(elapsed, 3),
|
||||
)
|
||||
|
||||
# If submission failed, preserve SQLite session for retry
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -32,6 +32,8 @@ from app.services.patient_submission_service import PatientSubmissionService
|
||||
from app.services.result_extraction_service import ResultExtractionService
|
||||
from app.services.voice_service import VoiceService
|
||||
|
||||
from app.core.security import get_auth_context, AuthContext, check_rate_limit
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/voice-onboarding",
|
||||
tags=["Voice Onboarding"],
|
||||
@@ -44,8 +46,12 @@ router = APIRouter(
|
||||
)
|
||||
async def start_voice_onboarding(
|
||||
request: StartVoiceOnboardingRequest,
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=15, window_sec=60)
|
||||
start_time = time.time()
|
||||
try:
|
||||
onboarding_service = OnboardingService()
|
||||
@@ -60,9 +66,11 @@ async def start_voice_onboarding(
|
||||
name="General Medicine / Primary Care (Auto-Triage)",
|
||||
)
|
||||
|
||||
patient_id = request.patient.id or auth.patient_id
|
||||
|
||||
session = OnboardingSession(
|
||||
session_id=session_id,
|
||||
patient_id=request.patient.id,
|
||||
patient_id=patient_id,
|
||||
patient_name=request.patient.name,
|
||||
age=request.patient.age,
|
||||
gender=request.patient.gender,
|
||||
@@ -131,8 +139,12 @@ async def voice_turn(
|
||||
session_id: str,
|
||||
audio: Optional[UploadFile] = File(None),
|
||||
text_fallback: Optional[str] = Form(None),
|
||||
req_obj: Request = None,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if req_obj and req_obj.client:
|
||||
check_rate_limit(req_obj.client.host, limit=20, window_sec=60)
|
||||
start_time = time.time()
|
||||
onboarding_service = OnboardingService()
|
||||
voice_service = VoiceService()
|
||||
@@ -146,10 +158,21 @@ async def voice_turn(
|
||||
detail="Voice onboarding session not found",
|
||||
)
|
||||
|
||||
if session.status != "in_progress":
|
||||
# 1b. IDOR Session Ownership Check
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden: You do not have authorization to access or modify this voice session.",
|
||||
)
|
||||
|
||||
if session.status == "completed":
|
||||
session.status = "in_progress"
|
||||
db.commit()
|
||||
|
||||
if session.status not in ["in_progress", "completed"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Voice onboarding session is not active",
|
||||
detail="Voice onboarding session is closed or submitted.",
|
||||
)
|
||||
|
||||
# 2. Fast-path transcript: use real-time Web Speech text_fallback first for zero-latency processing
|
||||
@@ -244,6 +267,11 @@ async def voice_turn(
|
||||
# 6. Update Structured Clinical State
|
||||
current_state = json.loads(state.state_json)
|
||||
updates = ai_response.get("state_updates", {})
|
||||
if hasattr(updates, "model_dump"):
|
||||
updates = updates.model_dump()
|
||||
elif not isinstance(updates, dict):
|
||||
updates = {}
|
||||
|
||||
for k, v in updates.items():
|
||||
if v is not None:
|
||||
if isinstance(v, list) and len(v) == 0:
|
||||
@@ -348,6 +376,7 @@ async def get_audio(audio_id: str):
|
||||
async def complete_voice_onboarding(
|
||||
session_id: str,
|
||||
request: CompleteOnboardingRequest,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
start_time = time.time()
|
||||
@@ -363,6 +392,13 @@ async def complete_voice_onboarding(
|
||||
detail="Voice onboarding session not found",
|
||||
)
|
||||
|
||||
# 1b. Verify Session Ownership (IDOR Prevention)
|
||||
if session.patient_id != auth.patient_id and auth.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Forbidden: You do not have authorization to complete this voice session.",
|
||||
)
|
||||
|
||||
state = onboarding_service.get_state(db, session_id)
|
||||
|
||||
# Check if there is any conversation to summarize
|
||||
@@ -389,6 +425,20 @@ async def complete_voice_onboarding(
|
||||
resp_ms = round(elapsed * 1000, 2)
|
||||
resp_sec = round(elapsed, 3)
|
||||
|
||||
if isinstance(result, dict):
|
||||
result["onboarding_completed"] = True
|
||||
|
||||
if session.status == "completed":
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Voice onboarding session is already completed.",
|
||||
response_time_ms=resp_ms,
|
||||
response_time_sec=resp_sec,
|
||||
)
|
||||
|
||||
if not request.confirmed:
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
@@ -403,15 +453,14 @@ async def complete_voice_onboarding(
|
||||
submission = await submission_service.submit(result)
|
||||
|
||||
if submission.get("success"):
|
||||
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
|
||||
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
|
||||
db.delete(session)
|
||||
session.status = "completed"
|
||||
session.stage = "confirmation"
|
||||
db.commit()
|
||||
|
||||
return CompleteOnboardingResponse(
|
||||
success=True,
|
||||
session_id=session_id,
|
||||
status="submitted_and_cleaned",
|
||||
status="completed",
|
||||
result=result,
|
||||
message="Voice onboarding successfully saved to patient record.",
|
||||
response_time_ms=resp_ms,
|
||||
|
||||
Reference in New Issue
Block a user