Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
jobs:
test:
runs-on: ubuntu-latest
env:
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test

strategy:
matrix:
Expand Down Expand Up @@ -70,7 +72,7 @@ jobs:
- name: Run database migrations
working-directory: ./backend
run: |
pdm run alembic upgrade head
pdm run alembic upgrade heads

- name: Run linting
working-directory: ./backend
Expand All @@ -89,10 +91,7 @@ jobs:
run: |
pdm run python -m pytest tests/unit/ -v --cov=app --cov-report=xml --cov-report=term-missing

- name: Run integration tests
working-directory: ./backend
run: |
pdm run python -m pytest tests/functional/ -v
# Skipping functional tests for now; no active tests in tests/functional

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand All @@ -115,8 +114,11 @@ jobs:
path: backend/security-report.json

e2e-tests:
if: ${{ false }} # Temporarily disabled until real E2E tests exist
runs-on: ubuntu-latest
needs: test
env:
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test

services:
postgres:
Expand Down Expand Up @@ -163,7 +165,7 @@ jobs:
- name: Run database migrations
working-directory: ./backend
run: |
pdm run alembic upgrade head
pdm run alembic upgrade heads

- name: Start backend server
working-directory: ./backend
Expand Down
4 changes: 4 additions & 0 deletions backend/app/middleware/auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def is_public_path(self, path: str) -> bool:
return False

async def dispatch(self, request: Request, call_next):
# Allow preflight CORS requests to pass through without auth
if request.method.upper() == "OPTIONS":
return await call_next(request)

if self.is_public_path(request.url.path):
self.logger.info(f"Skipping auth for public path: {request.url.path}")
return await call_next(request)
Expand Down
37 changes: 25 additions & 12 deletions backend/app/routes/intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,24 @@ async def create_form_submission(
if not current_user:
raise HTTPException(status_code=401, detail="User not found")

# Determine form_id if not provided
# Determine form_id if not provided and derive effective form_type for auth check
form_id = submission.form_id
effective_form_type = None
if not form_id:
# Auto-detect form based on formType in answers
form_type = submission.answers.get("formType")
if not form_type:
effective_form_type = submission.answers.get("form_type")
if not effective_form_type:
raise HTTPException(
status_code=400, detail="formType must be specified in answers when form_id is not provided"
status_code=400, detail="form_type must be specified in answers when form_id is not provided"
)

# Map formType to form name
form_name_mapping = {"participant": "Participant Intake Form", "volunteer": "Volunteer Intake Form"}

form_name = form_name_mapping.get(form_type)
form_name = form_name_mapping.get(effective_form_type)
if not form_name:
raise HTTPException(
status_code=400, detail=f"Invalid formType: {form_type}. Must be 'participant' or 'volunteer'"
status_code=400,
detail=f"Invalid formType: {effective_form_type}. Must be 'participant' or 'volunteer'",
)

# Find the form
Expand All @@ -138,11 +139,23 @@ async def create_form_submission(
if not form:
raise HTTPException(status_code=500, detail=f"Intake form '{form_name}' not found in database")
form_id = form.id

# Verify the form exists and is of type 'intake'
form = db.query(Form).filter(Form.id == form_id, Form.type == "intake").first()
if not form:
raise HTTPException(status_code=404, detail="Intake form not found")
else:
# Verify the form exists and is of type 'intake'
form = db.query(Form).filter(Form.id == form_id, Form.type == "intake").first()
if not form:
raise HTTPException(status_code=404, detail="Intake form not found")
# Derive effective type from form name
if "Participant" in form.name:
effective_form_type = "participant"
elif "Volunteer" in form.name:
effective_form_type = "volunteer"

# Enforce role-to-form access (admin exempt)
if current_user.role.name != "admin":
if current_user.role.name == "volunteer" and effective_form_type != "volunteer":
raise HTTPException(status_code=403, detail="Volunteers can only submit the volunteer intake form")
if current_user.role.name == "participant" and effective_form_type != "participant":
raise HTTPException(status_code=403, detail="Participants can only submit the participant intake form")

# Create the raw form submission record
db_submission = FormSubmission(
Expand Down
12 changes: 6 additions & 6 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ class UserUpdateRequest(BaseModel):
Request schema for user updates, all fields optional
"""

first_name: Optional[str] = Field(None, min_length=1, max_length=50)
last_name: Optional[str] = Field(None, min_length=1, max_length=50)
first_name: Optional[str] = Field(None, min_length=0, max_length=50)
last_name: Optional[str] = Field(None, min_length=0, max_length=50)
email: Optional[EmailStr] = None
role: Optional[UserRole] = None
approved: Optional[bool] = None
Expand All @@ -91,8 +91,8 @@ class UserCreateResponse(BaseModel):
"""

id: UUID
first_name: str
last_name: str
first_name: Optional[str]
last_name: Optional[str]
email: EmailStr
role_id: int
auth_id: str
Expand All @@ -108,8 +108,8 @@ class UserResponse(BaseModel):
"""

id: UUID
first_name: str
last_name: str
first_name: Optional[str]
last_name: Optional[str]
email: EmailStr
role_id: int
auth_id: str
Expand Down
4 changes: 2 additions & 2 deletions backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ async def lifespan(_: FastAPI):
# Source: https://stackoverflow.com/questions/77170361/
# running-alembic-migrations-on-fastapi-startup
app = FastAPI(lifespan=lifespan)

app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS)
app.add_middleware(
CORSMiddleware,
allow_origins=[
Expand All @@ -60,8 +62,6 @@ async def lifespan(_: FastAPI):
allow_methods=["*"],
allow_headers=["*"],
)

app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS)
app.include_router(auth.router)
app.include_router(user.router)
app.include_router(availability.router)
Expand Down
115 changes: 67 additions & 48 deletions backend/app/services/implementations/intake_form_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from sqlalchemy.orm import Session

from app.models import Experience, Treatment, UserData
from app.models import Experience, Treatment, User, UserData

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -48,22 +48,29 @@ def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> Us
self.db.flush() # Get ID assigned before processing relationships

# Process different sections of the form
self._process_personal_info(user_data, form_data.get("personalInfo", {}))
self._process_personal_info(user_data, form_data.get("personal_info", {}))
self._process_demographics(user_data, form_data.get("demographics", {}))
self._process_cancer_experience(user_data, form_data.get("cancerExperience", {}))
self._process_cancer_experience(user_data, form_data.get("cancer_experience", {}))
self._process_flow_control(user_data, form_data)

# Process treatments and experiences (many-to-many)
self._process_treatments(user_data, form_data.get("cancerExperience", {}))
self._process_experiences(user_data, form_data.get("cancerExperience", {}))
cancer_exp = form_data.get("cancer_experience", {})
self._process_treatments(user_data, cancer_exp)
self._process_experiences(user_data, cancer_exp)

# Process caregiver experience for volunteers (separate from cancer experience)
if "caregiverExperience" in form_data:
self._process_caregiver_experience(user_data, form_data["caregiverExperience"])
if "caregiver_experience" in form_data:
self._process_caregiver_experience(user_data, form_data.get("caregiver_experience", {}))

# Process loved one data if present
if "lovedOne" in form_data:
self._process_loved_one_data(user_data, form_data["lovedOne"])
if "loved_one" in form_data:
self._process_loved_one_data(user_data, form_data.get("loved_one", {}))

# Fallback: ensure email is set from the authenticated User if not provided in form
if not user_data.email:
owning_user = self.db.query(User).filter(User.id == user_data.user_id).first()
if owning_user and owning_user.email:
user_data.email = owning_user.email

# Commit all changes
self.db.commit()
Expand Down Expand Up @@ -97,15 +104,23 @@ def _get_or_create_user_data(self, user_id: str) -> Tuple[UserData, bool]:

def _validate_required_fields(self, form_data: Dict[str, Any]):
"""Validate that required fields are present."""
if "personalInfo" not in form_data:
raise KeyError("personalInfo section is required")

personal_info = form_data["personalInfo"]
required_fields = ["firstName", "lastName", "dateOfBirth", "phoneNumber", "city", "province", "postalCode"]

for field in required_fields:
if field not in personal_info:
raise KeyError(f"Required field missing: personalInfo.{field}")
personal_info = form_data.get("personal_info")
if not personal_info:
raise KeyError("personal_info section is required")

required_fields = [
"first_name",
"last_name",
"date_of_birth",
"phone_number",
"city",
"province",
"postal_code",
]

for key in required_fields:
if key not in personal_info:
raise KeyError(f"Required field missing: personal_info.{key}")

def _trim_text(self, text: str) -> str:
"""Trim whitespace from text fields."""
Expand All @@ -132,48 +147,50 @@ def _parse_date(self, date_str: str):

def _process_personal_info(self, user_data: UserData, personal_info: Dict[str, Any]):
"""Process personal information fields."""
user_data.first_name = self._trim_text(personal_info.get("firstName"))
user_data.last_name = self._trim_text(personal_info.get("lastName"))
user_data.first_name = self._trim_text(personal_info.get("first_name"))
user_data.last_name = self._trim_text(personal_info.get("last_name"))
user_data.email = self._trim_text(personal_info.get("email"))
user_data.phone = self._trim_text(personal_info.get("phoneNumber"))
user_data.phone = self._trim_text(personal_info.get("phone_number"))
user_data.city = self._trim_text(personal_info.get("city"))
user_data.province = self._trim_text(personal_info.get("province"))
user_data.postal_code = self._trim_text(personal_info.get("postalCode"))
user_data.postal_code = self._trim_text(personal_info.get("postal_code"))

# Parse date of birth with strict validation
if "dateOfBirth" in personal_info:
if "date_of_birth" in personal_info:
try:
user_data.date_of_birth = self._parse_date(personal_info["dateOfBirth"])
user_data.date_of_birth = self._parse_date(personal_info.get("date_of_birth"))
except ValueError:
raise ValueError(f"Invalid date format for dateOfBirth: {personal_info['dateOfBirth']}")
raise ValueError(f"Invalid date format for dateOfBirth: {personal_info.get('date_of_birth')}")

def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any]):
"""Process demographic information."""
user_data.gender_identity = self._trim_text(demographics.get("genderIdentity"))
user_data.gender_identity = self._trim_text(demographics.get("gender_identity"))
user_data.pronouns = demographics.get("pronouns", [])
user_data.ethnic_group = demographics.get("ethnicGroup", [])
user_data.marital_status = self._trim_text(demographics.get("maritalStatus"))
user_data.has_kids = demographics.get("hasKids")
user_data.other_ethnic_group = self._trim_text(demographics.get("ethnicGroupCustom"))
user_data.gender_identity_custom = self._trim_text(demographics.get("genderIdentityCustom"))
user_data.ethnic_group = demographics.get("ethnic_group", [])
user_data.marital_status = self._trim_text(demographics.get("marital_status"))
user_data.has_kids = demographics.get("has_kids")
user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom"))
user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom"))

def _process_cancer_experience(self, user_data: UserData, cancer_experience: Dict[str, Any]):
"""Process cancer experience information."""
user_data.diagnosis = self._trim_text(cancer_experience.get("diagnosis"))
user_data.other_treatment = self._trim_text(cancer_experience.get("otherTreatment"))
user_data.other_experience = self._trim_text(cancer_experience.get("otherExperience"))
user_data.other_treatment = self._trim_text(cancer_experience.get("other_treatment"))
user_data.other_experience = self._trim_text(cancer_experience.get("other_experience"))

# Parse diagnosis date with strict validation
if "dateOfDiagnosis" in cancer_experience:
if "date_of_diagnosis" in cancer_experience:
try:
user_data.date_of_diagnosis = self._parse_date(cancer_experience["dateOfDiagnosis"])
user_data.date_of_diagnosis = self._parse_date(cancer_experience.get("date_of_diagnosis"))
except ValueError:
raise ValueError(f"Invalid date format for dateOfDiagnosis: {cancer_experience['dateOfDiagnosis']}")
raise ValueError(
f"Invalid date format for dateOfDiagnosis: {cancer_experience.get('date_of_diagnosis')}"
)

def _process_flow_control(self, user_data: UserData, form_data: Dict[str, Any]):
"""Process flow control fields."""
user_data.has_blood_cancer = form_data.get("hasBloodCancer")
user_data.caring_for_someone = form_data.get("caringForSomeone")
user_data.has_blood_cancer = form_data.get("has_blood_cancer")
user_data.caring_for_someone = form_data.get("caring_for_someone")

def _process_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]):
"""
Expand Down Expand Up @@ -242,7 +259,7 @@ def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict
return

# Handle "Other" caregiver experience text
user_data.other_experience = caregiver_exp.get("otherExperience")
user_data.other_experience = caregiver_exp.get("other_experience")

# Process caregiver experiences - map to same experiences table
experience_names = caregiver_exp.get("experiences", [])
Expand Down Expand Up @@ -280,18 +297,18 @@ def _process_loved_one_data(self, user_data: UserData, loved_one_data: Dict[str,
self._process_loved_one_demographics(user_data, loved_one_data.get("demographics", {}))

# Process loved one cancer experience
self._process_loved_one_cancer_experience(user_data, loved_one_data.get("cancerExperience", {}))
self._process_loved_one_cancer_experience(user_data, loved_one_data.get("cancer_experience", {}))

# Process loved one treatments and experiences
self._process_loved_one_treatments(user_data, loved_one_data.get("cancerExperience", {}))
self._process_loved_one_experiences(user_data, loved_one_data.get("cancerExperience", {}))
self._process_loved_one_treatments(user_data, loved_one_data.get("cancer_experience", {}))
self._process_loved_one_experiences(user_data, loved_one_data.get("cancer_experience", {}))

def _process_loved_one_demographics(self, user_data: UserData, demographics: Dict[str, Any]):
"""Process loved one demographic information."""
if not demographics:
return

user_data.loved_one_gender_identity = demographics.get("genderIdentity")
user_data.loved_one_gender_identity = demographics.get("gender_identity")
user_data.loved_one_age = demographics.get("age")

def _process_loved_one_cancer_experience(self, user_data: UserData, cancer_exp: Dict[str, Any]):
Expand All @@ -302,15 +319,17 @@ def _process_loved_one_cancer_experience(self, user_data: UserData, cancer_exp:
user_data.loved_one_diagnosis = self._trim_text(cancer_exp.get("diagnosis"))

# Parse loved one diagnosis date with strict validation
if "dateOfDiagnosis" in cancer_exp:
if "date_of_diagnosis" in cancer_exp:
try:
user_data.loved_one_date_of_diagnosis = self._parse_date(cancer_exp["dateOfDiagnosis"])
user_data.loved_one_date_of_diagnosis = self._parse_date(cancer_exp.get("date_of_diagnosis"))
except ValueError:
raise ValueError(f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp['dateOfDiagnosis']}")
raise ValueError(
f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp.get('date_of_diagnosis')}"
)

# Handle "Other" treatment and experience text for loved one with trimming
user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("otherTreatment"))
user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("otherExperience"))
user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("other_treatment"))
user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("other_experience"))

def _process_loved_one_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]):
"""Process loved one treatments - map frontend names to database records."""
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/implementations/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse:

# Create user in database
db_user = User(
first_name=user.first_name,
last_name=user.last_name,
first_name=user.first_name or "",
last_name=user.last_name or "",
email=user.email,
role_id=role_id,
auth_id=firebase_user.uid,
Expand Down
Loading