diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a1795010..e142d17e 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -15,6 +15,8 @@ on: jobs: test: runs-on: ubuntu-latest + env: + POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test strategy: matrix: @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py index 9f752a08..8f8e52c4 100644 --- a/backend/app/middleware/auth_middleware.py +++ b/backend/app/middleware/auth_middleware.py @@ -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) diff --git a/backend/app/routes/intake.py b/backend/app/routes/intake.py index 987575e8..dc7a4491 100644 --- a/backend/app/routes/intake.py +++ b/backend/app/routes/intake.py @@ -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 @@ -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( diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 89212972..2d7d8eca 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 @@ -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 @@ -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 diff --git a/backend/app/server.py b/backend/app/server.py index 41c759d6..6be61f43 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -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=[ @@ -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) diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 1dd12030..9f78759e 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -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__) @@ -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() @@ -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.""" @@ -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]): """ @@ -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", []) @@ -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]): @@ -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.""" diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 40c1930f..610eabb9 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -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, diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index a9ce4362..5603e8d9 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -120,32 +120,32 @@ def test_participant_with_cancer_only(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "John", - "lastName": "Doe", - "dateOfBirth": "15/03/1985", - "phoneNumber": "555-123-4567", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "John", + "last_name": "Doe", + "date_of_birth": "15/03/1985", + "phone_number": "555-123-4567", "city": "Toronto", "province": "Ontario", - "postalCode": "M1A 1A1", + "postal_code": "M1A 1A1", }, "demographics": { - "genderIdentity": "Male", + "gender_identity": "Male", "pronouns": ["he", "him"], - "ethnicGroup": ["White"], - "maritalStatus": "Married", - "hasKids": "yes", + "ethnic_group": ["White"], + "marital_status": "Married", + "has_kids": "yes", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Leukemia", - "dateOfDiagnosis": "01/01/2023", + "date_of_diagnosis": "01/01/2023", "treatments": ["Chemotherapy", "Surgery"], "experiences": ["Anxiety", "Fatigue"], - "otherTreatment": "Some custom treatment details", - "otherExperience": "Custom experience notes", + "other_treatment": "Some custom treatment details", + "other_experience": "Custom experience notes", }, } @@ -209,32 +209,32 @@ def test_custom_treatments_and_experiences(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Jane", - "lastName": "Smith", - "dateOfBirth": "20/12/1990", - "phoneNumber": "555-987-6543", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Jane", + "last_name": "Smith", + "date_of_birth": "20/12/1990", + "phone_number": "555-987-6543", "city": "Vancouver", "province": "British Columbia", - "postalCode": "V6B 1A1", + "postal_code": "V6B 1A1", }, "demographics": { - "genderIdentity": "Female", + "gender_identity": "Female", "pronouns": ["she", "her"], - "ethnicGroup": ["Asian"], - "maritalStatus": "Single", - "hasKids": "no", + "ethnic_group": ["Asian"], + "marital_status": "Single", + "has_kids": "no", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Lymphoma", - "dateOfDiagnosis": "15/06/2022", + "date_of_diagnosis": "15/06/2022", "treatments": ["Custom Treatment X", "Experimental Therapy Y"], # New treatments "experiences": ["Custom Symptom A", "Unique Experience B"], # New experiences - "otherTreatment": "Details about experimental treatment", - "otherExperience": "Unique side effects experienced", + "other_treatment": "Details about experimental treatment", + "other_experience": "Unique side effects experienced", }, } @@ -283,38 +283,38 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "volunteer", - "hasBloodCancer": "no", - "caringForSomeone": "yes", - "personalInfo": { - "firstName": "Alice", - "lastName": "Volunteer", - "dateOfBirth": "25/08/1975", - "phoneNumber": "555-111-2222", + "form_type": "volunteer", + "has_blood_cancer": "no", + "caring_for_someone": "yes", + "personal_info": { + "first_name": "Alice", + "last_name": "Volunteer", + "date_of_birth": "25/08/1975", + "phone_number": "555-111-2222", "city": "Calgary", "province": "Alberta", - "postalCode": "T2A 1A1", + "postal_code": "T2A 1A1", }, "demographics": { - "genderIdentity": "Female", + "gender_identity": "Female", "pronouns": ["she", "her"], - "ethnicGroup": ["Indigenous"], - "maritalStatus": "Divorced", - "hasKids": "yes", + "ethnic_group": ["Indigenous"], + "marital_status": "Divorced", + "has_kids": "yes", }, - "caregiverExperience": { # Note: caregiverExperience, not cancerExperience + "caregiver_experience": { "experiences": ["Financial Stress", "Relationship Changes"], - "otherExperience": "Dealing with healthcare system complexity", + "other_experience": "Dealing with healthcare system complexity", }, - "lovedOne": { - "demographics": {"genderIdentity": "Male", "age": "45-54"}, - "cancerExperience": { + "loved_one": { + "demographics": {"gender_identity": "Male", "age": "45-54"}, + "cancer_experience": { "diagnosis": "Brain Cancer", - "dateOfDiagnosis": "10/05/2020", + "date_of_diagnosis": "10/05/2020", "treatments": ["Surgery", "Radiation Therapy"], "experiences": ["Depression", "Cognitive Changes"], - "otherTreatment": "Specialized brain surgery", - "otherExperience": "Memory issues post-surgery", + "other_treatment": "Specialized brain surgery", + "other_experience": "Memory issues post-surgery", }, }, } @@ -375,44 +375,44 @@ def test_form_submission_json_structure(db_session, test_user): # Arrange - Complex form data with nested structures processor = IntakeFormProcessor(db_session) complex_form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "yes", - "personalInfo": { - "firstName": "Maria", - "lastName": "Complex", - "dateOfBirth": "12/11/1988", - "phoneNumber": "555-999-8888", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "yes", + "personal_info": { + "first_name": "Maria", + "last_name": "Complex", + "date_of_birth": "12/11/1988", + "phone_number": "555-999-8888", "city": "Edmonton", "province": "Alberta", - "postalCode": "T5A 2B2", + "postal_code": "T5A 2B2", }, "demographics": { - "genderIdentity": "Self-describe", - "genderIdentityCustom": "Non-binary", + "gender_identity": "Self-describe", + "gender_identity_custom": "Non-binary", "pronouns": ["they", "them"], - "ethnicGroup": ["Other", "Asian"], - "ethnicGroupCustom": "Mixed heritage - Filipino and Indigenous", - "maritalStatus": "Common-law", - "hasKids": "yes", + "ethnic_group": ["Other", "Asian"], + "ethnic_group_custom": "Mixed heritage - Filipino and Indigenous", + "marital_status": "Common-law", + "has_kids": "yes", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Ovarian Cancer", - "dateOfDiagnosis": "03/07/2022", + "date_of_diagnosis": "03/07/2022", "treatments": ["Chemotherapy", "Custom Treatment Protocol"], "experiences": ["Anxiety", "Custom Side Effect"], - "otherTreatment": "Experimental immunotherapy trial", - "otherExperience": "Severe neuropathy affecting daily activities", + "other_treatment": "Experimental immunotherapy trial", + "other_experience": "Severe neuropathy affecting daily activities", }, - "lovedOne": { - "demographics": {"genderIdentity": "Female", "age": "65+"}, - "cancerExperience": { + "loved_one": { + "demographics": {"gender_identity": "Female", "age": "65+"}, + "cancer_experience": { "diagnosis": "Lung Cancer", - "dateOfDiagnosis": "15/01/2021", + "date_of_diagnosis": "15/01/2021", "treatments": ["Radiation Therapy", "Palliative Care"], "experiences": ["Sleep Problems", "Loss of Appetite"], - "otherTreatment": "Comfort care measures", - "otherExperience": "End-of-life care planning", + "other_treatment": "Comfort care measures", + "other_experience": "End-of-life care planning", }, }, } @@ -467,26 +467,26 @@ def test_empty_and_minimal_data_handling(db_session, test_user): # Arrange - Minimal form data processor = IntakeFormProcessor(db_session) minimal_form_data = { - "formType": "volunteer", - "hasBloodCancer": "no", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Min", - "lastName": "Imal", - "dateOfBirth": "01/01/2000", - "phoneNumber": "", # Empty string + "form_type": "volunteer", + "has_blood_cancer": "no", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Min", + "last_name": "Imal", + "date_of_birth": "01/01/2000", + "phone_number": "", # Empty string "city": "Toronto", "province": "Ontario", - "postalCode": "M1A 1A1", + "postal_code": "M1A 1A1", }, "demographics": { - "genderIdentity": "Prefer not to say", + "gender_identity": "Prefer not to say", "pronouns": [], # Empty array - "ethnicGroup": [], # Empty array - "maritalStatus": "", # Empty string - "hasKids": "", + "ethnic_group": [], # Empty array + "marital_status": "", # Empty string + "has_kids": "", }, - # No cancerExperience, caregiverExperience, or lovedOne sections + # No cancer_experience, caregiver_experience, or loved_one sections } # Act @@ -524,34 +524,34 @@ def test_participant_caregiver_without_cancer(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "no", - "caringForSomeone": "yes", - "personalInfo": { - "firstName": "Sarah", - "lastName": "Caregiver", - "dateOfBirth": "10/09/1975", - "phoneNumber": "555-222-3333", + "form_type": "participant", + "has_blood_cancer": "no", + "caring_for_someone": "yes", + "personal_info": { + "first_name": "Sarah", + "last_name": "Caregiver", + "date_of_birth": "10/09/1975", + "phone_number": "555-222-3333", "city": "Ottawa", "province": "Ontario", - "postalCode": "K1A 0A6", + "postal_code": "K1A 0A6", }, "demographics": { - "genderIdentity": "Female", + "gender_identity": "Female", "pronouns": ["she", "her"], - "ethnicGroup": ["Black"], - "maritalStatus": "Married", - "hasKids": "yes", + "ethnic_group": ["Black"], + "marital_status": "Married", + "has_kids": "yes", }, - "lovedOne": { - "demographics": {"genderIdentity": "Male", "age": "55-64"}, - "cancerExperience": { + "loved_one": { + "demographics": {"gender_identity": "Male", "age": "55-64"}, + "cancer_experience": { "diagnosis": "Prostate Cancer", - "dateOfDiagnosis": "20/03/2021", + "date_of_diagnosis": "20/03/2021", "treatments": ["Surgery", "Hormone Therapy"], "experiences": ["Anxiety", "Relationship Changes"], - "otherTreatment": "Robotic surgery", - "otherExperience": "Intimacy concerns", + "other_treatment": "Robotic surgery", + "other_experience": "Intimacy concerns", }, }, } @@ -606,43 +606,43 @@ def test_participant_cancer_patient_and_caregiver(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "yes", - "personalInfo": { - "firstName": "David", - "lastName": "BothRoles", - "dateOfBirth": "05/11/1980", - "phoneNumber": "555-444-5555", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "yes", + "personal_info": { + "first_name": "David", + "last_name": "BothRoles", + "date_of_birth": "05/11/1980", + "phone_number": "555-444-5555", "city": "Halifax", "province": "Nova Scotia", - "postalCode": "B3H 3C3", + "postal_code": "B3H 3C3", }, "demographics": { - "genderIdentity": "Male", + "gender_identity": "Male", "pronouns": ["he", "him"], - "ethnicGroup": ["White", "Other"], - "ethnicGroupCustom": "Mixed European heritage", - "maritalStatus": "Married", - "hasKids": "yes", + "ethnic_group": ["White", "Other"], + "ethnic_group_custom": "Mixed European heritage", + "marital_status": "Married", + "has_kids": "yes", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Lymphoma", - "dateOfDiagnosis": "15/08/2022", + "date_of_diagnosis": "15/08/2022", "treatments": ["Chemotherapy", "Radiation Therapy"], "experiences": ["Fatigue", "Depression"], - "otherTreatment": "Targeted therapy", - "otherExperience": "Cognitive fog", + "other_treatment": "Targeted therapy", + "other_experience": "Cognitive fog", }, - "lovedOne": { - "demographics": {"genderIdentity": "Female", "age": "35-44"}, - "cancerExperience": { + "loved_one": { + "demographics": {"gender_identity": "Female", "age": "35-44"}, + "cancer_experience": { "diagnosis": "Breast Cancer", - "dateOfDiagnosis": "10/01/2023", + "date_of_diagnosis": "10/01/2023", "treatments": ["Surgery", "Chemotherapy"], "experiences": ["Hair Loss", "Body Image Issues"], - "otherTreatment": "Reconstruction surgery", - "otherExperience": "Fertility concerns", + "other_treatment": "Reconstruction surgery", + "other_experience": "Fertility concerns", }, }, } @@ -697,26 +697,26 @@ def test_participant_no_cancer_experience(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "no", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Emma", - "lastName": "NoCancer", - "dateOfBirth": "22/04/1995", - "phoneNumber": "555-777-8888", + "form_type": "participant", + "has_blood_cancer": "no", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Emma", + "last_name": "NoCancer", + "date_of_birth": "22/04/1995", + "phone_number": "555-777-8888", "city": "Winnipeg", "province": "Manitoba", - "postalCode": "R3C 3P4", + "postal_code": "R3C 3P4", }, "demographics": { - "genderIdentity": "Female", + "gender_identity": "Female", "pronouns": ["she", "her"], - "ethnicGroup": ["Asian", "Indigenous"], - "maritalStatus": "Single", - "hasKids": "no", + "ethnic_group": ["Asian", "Indigenous"], + "marital_status": "Single", + "has_kids": "no", }, - # No cancerExperience, caregiverExperience, or lovedOne sections + # No cancer_experience, caregiver_experience, or loved_one sections } # Act @@ -766,32 +766,32 @@ def test_volunteer_cancer_patient_only(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "volunteer", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Michael", - "lastName": "VolunteerSurvivor", - "dateOfBirth": "18/07/1970", - "phoneNumber": "555-101-2020", + "form_type": "volunteer", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Michael", + "last_name": "VolunteerSurvivor", + "date_of_birth": "18/07/1970", + "phone_number": "555-101-2020", "city": "Regina", "province": "Saskatchewan", - "postalCode": "S4P 3Y2", + "postal_code": "S4P 3Y2", }, "demographics": { - "genderIdentity": "Male", + "gender_identity": "Male", "pronouns": ["he", "him"], - "ethnicGroup": ["Indigenous"], - "maritalStatus": "Widowed", - "hasKids": "yes", + "ethnic_group": ["Indigenous"], + "marital_status": "Widowed", + "has_kids": "yes", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Myeloma", - "dateOfDiagnosis": "12/05/2019", + "date_of_diagnosis": "12/05/2019", "treatments": ["Chemotherapy", "Stem Cell Transplant"], "experiences": ["Depression", "Survivorship Concerns"], - "otherTreatment": "Maintenance therapy", - "otherExperience": "Long-term survivor guilt", + "other_treatment": "Maintenance therapy", + "other_experience": "Long-term survivor guilt", }, } @@ -839,42 +839,42 @@ def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "volunteer", - "hasBloodCancer": "yes", - "caringForSomeone": "yes", - "personalInfo": { - "firstName": "Lisa", - "lastName": "VolunteerBoth", - "dateOfBirth": "03/12/1965", - "phoneNumber": "555-303-4040", + "form_type": "volunteer", + "has_blood_cancer": "yes", + "caring_for_someone": "yes", + "personal_info": { + "first_name": "Lisa", + "last_name": "VolunteerBoth", + "date_of_birth": "03/12/1965", + "phone_number": "555-303-4040", "city": "Victoria", "province": "British Columbia", - "postalCode": "V8W 1P6", + "postal_code": "V8W 1P6", }, "demographics": { - "genderIdentity": "Female", + "gender_identity": "Female", "pronouns": ["she", "her"], - "ethnicGroup": ["White"], - "maritalStatus": "Married", - "hasKids": "yes", + "ethnic_group": ["White"], + "marital_status": "Married", + "has_kids": "yes", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Breast Cancer", - "dateOfDiagnosis": "08/11/2015", + "date_of_diagnosis": "08/11/2015", "treatments": ["Surgery", "Chemotherapy", "Radiation Therapy"], "experiences": ["Hair Loss", "Survivorship Concerns"], - "otherTreatment": "Hormone blocking therapy", - "otherExperience": "10-year survivor perspective", + "other_treatment": "Hormone blocking therapy", + "other_experience": "10-year survivor perspective", }, - "lovedOne": { - "demographics": {"genderIdentity": "Male", "age": "65+"}, - "cancerExperience": { + "loved_one": { + "demographics": {"gender_identity": "Male", "age": "65+"}, + "cancer_experience": { "diagnosis": "Pancreatic Cancer", - "dateOfDiagnosis": "25/09/2023", + "date_of_diagnosis": "25/09/2023", "treatments": ["Surgery", "Palliative Care"], "experiences": ["Loss of Appetite", "Fatigue"], - "otherTreatment": "Whipple procedure", - "otherExperience": "End-of-life discussions", + "other_treatment": "Whipple procedure", + "other_experience": "End-of-life discussions", }, }, } @@ -924,26 +924,26 @@ def test_volunteer_no_cancer_experience(db_session, test_user): # Arrange processor = IntakeFormProcessor(db_session) form_data = { - "formType": "volunteer", - "hasBloodCancer": "no", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Robert", - "lastName": "VolunteerHelper", - "dateOfBirth": "14/06/1985", - "phoneNumber": "555-505-6060", + "form_type": "volunteer", + "has_blood_cancer": "no", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Robert", + "last_name": "VolunteerHelper", + "date_of_birth": "14/06/1985", + "phone_number": "555-505-6060", "city": "Fredericton", "province": "New Brunswick", - "postalCode": "E3B 5A3", + "postal_code": "E3B 5A3", }, "demographics": { - "genderIdentity": "Male", + "gender_identity": "Male", "pronouns": ["he", "him"], - "ethnicGroup": ["White"], - "maritalStatus": "Single", - "hasKids": "no", + "ethnic_group": ["White"], + "marital_status": "Single", + "has_kids": "no", }, - # No cancerExperience, caregiverExperience, or lovedOne sections + # No cancer_experience, caregiver_experience, or loved_one sections } # Act @@ -991,17 +991,17 @@ def test_invalid_user_id_format(db_session): """Test error handling with invalid UUID format""" processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Test", - "lastName": "User", - "dateOfBirth": "01/01/1990", - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Test", + "last_name": "User", + "date_of_birth": "01/01/1990", + "phone_number": "555-1234", "city": "Test City", "province": "Test Province", - "postalCode": "T1T 1T1", + "postal_code": "T1T 1T1", }, } @@ -1013,14 +1013,14 @@ def test_missing_personal_info_section(db_session, test_user): """Test error handling with missing personalInfo section""" processor = IntakeFormProcessor(db_session) - # Missing personalInfo entirely should raise KeyError - with pytest.raises(KeyError, match="personalInfo section is required"): + # Missing personal_info entirely should raise KeyError + with pytest.raises(KeyError, match="personal_info section is required"): processor.process_form_submission( str(test_user.id), { - "formType": "participant", - "hasBloodCancer": "yes", - # No personalInfo section + "form_type": "participant", + "has_blood_cancer": "yes", + # No personal_info section }, ) @@ -1029,15 +1029,15 @@ def test_missing_required_personal_info_fields(db_session, test_user): """Test error handling with missing required personalInfo fields""" processor = IntakeFormProcessor(db_session) - # Missing required personalInfo fields - with pytest.raises(KeyError, match="Required field missing: personalInfo.lastName"): + # Missing required personal_info fields + with pytest.raises(KeyError, match="Required field missing: personal_info.last_name"): processor.process_form_submission( str(test_user.id), { - "formType": "participant", - "hasBloodCancer": "yes", - "personalInfo": { - "firstName": "Test" + "form_type": "participant", + "has_blood_cancer": "yes", + "personal_info": { + "first_name": "Test" # Missing other required fields }, }, @@ -1050,17 +1050,17 @@ def test_malformed_date_formats(db_session, test_user): # Invalid date format form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Test", - "lastName": "User", - "dateOfBirth": "invalid-date", - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Test", + "last_name": "User", + "date_of_birth": "invalid-date", + "phone_number": "555-1234", "city": "Test City", "province": "Test Province", - "postalCode": "T1T 1T1", + "postal_code": "T1T 1T1", }, } @@ -1080,16 +1080,16 @@ def test_database_rollback_on_error(db_session, test_user): processor.process_form_submission( str(test_user.id), { - "formType": "participant", - "hasBloodCancer": "yes", - "personalInfo": { - "firstName": "Test", - "lastName": "User", - "dateOfBirth": "invalid-date", # This will cause an error - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "personal_info": { + "first_name": "Test", + "last_name": "User", + "date_of_birth": "invalid-date", # This will cause an error + "phone_number": "555-1234", "city": "Test City", "province": "Test Province", - "postalCode": "T1T 1T1", + "postal_code": "T1T 1T1", }, }, ) @@ -1107,21 +1107,21 @@ def test_duplicate_form_submission_handling(db_session, test_user): try: processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Original", - "lastName": "User", - "dateOfBirth": "01/01/1990", - "phoneNumber": "555-1111", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Original", + "last_name": "User", + "date_of_birth": "01/01/1990", + "phone_number": "555-1111", "city": "Original City", "province": "Original Province", - "postalCode": "O1O 1O1", + "postal_code": "O1O 1O1", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Original Cancer", - "dateOfDiagnosis": "01/01/2020", + "date_of_diagnosis": "01/01/2020", "treatments": ["Surgery"], "experiences": ["Fatigue"], }, @@ -1132,9 +1132,9 @@ def test_duplicate_form_submission_handling(db_session, test_user): db_session.commit() # Second submission with different data (should update existing record) - form_data["personalInfo"]["firstName"] = "Updated" - form_data["personalInfo"]["city"] = "Updated City" - form_data["cancerExperience"]["diagnosis"] = "Updated Cancer" + form_data["personal_info"]["first_name"] = "Updated" + form_data["personal_info"]["city"] = "Updated City" + form_data["cancer_experience"]["diagnosis"] = "Updated Cancer" processor.process_form_submission(str(test_user.id), form_data) db_session.commit() @@ -1159,26 +1159,26 @@ def test_text_trimming_and_normalization(db_session, test_user): try: processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": " John ", # Extra spaces - "lastName": "\tDoe\n", # Tabs and newlines - "dateOfBirth": "01/01/1990", - "phoneNumber": " 555-1234 ", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": " John ", # Extra spaces + "last_name": "\tDoe\n", # Tabs and newlines + "date_of_birth": "01/01/1990", + "phone_number": " 555-1234 ", "city": " Toronto ", "province": " Ontario ", - "postalCode": " M5V 3A1 ", + "postal_code": " M5V 3A1 ", }, - "demographics": {"genderIdentity": " Male ", "maritalStatus": " Single "}, - "cancerExperience": { + "demographics": {"gender_identity": " Male ", "marital_status": " Single "}, + "cancer_experience": { "diagnosis": " Leukemia ", - "dateOfDiagnosis": "01/01/2020", + "date_of_diagnosis": "01/01/2020", "treatments": [" Surgery ", " Chemotherapy "], "experiences": [" Fatigue "], - "otherTreatment": " Custom treatment ", - "otherExperience": " Custom experience ", + "other_treatment": " Custom treatment ", + "other_experience": " Custom experience ", }, } @@ -1211,24 +1211,24 @@ def test_sql_injection_prevention(db_session, test_user): # Attempt SQL injection in various fields malicious_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "'; DROP TABLE users; --", - "lastName": "Robert'; DELETE FROM user_data; --", - "dateOfBirth": "01/01/1990", - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "'; DROP TABLE users; --", + "last_name": "Robert'; DELETE FROM user_data; --", + "date_of_birth": "01/01/1990", + "phone_number": "555-1234", "city": "Toronto'; SELECT * FROM users; --", "province": "Ontario", - "postalCode": "M5V 3A1", + "postal_code": "M5V 3A1", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "'; UNION SELECT password FROM users; --", - "dateOfDiagnosis": "01/01/2020", + "date_of_diagnosis": "01/01/2020", "treatments": ["Surgery"], "experiences": ["Fatigue"], - "otherTreatment": "'; INSERT INTO admin_users VALUES (1); --", + "other_treatment": "'; INSERT INTO admin_users VALUES (1); --", }, } @@ -1256,31 +1256,31 @@ def test_unicode_and_special_characters(db_session, test_user): try: processor = IntakeFormProcessor(db_session) form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "José", # Accented characters - "lastName": "François-Müller", # Multiple special chars - "dateOfBirth": "01/01/1990", - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "José", # Accented characters + "last_name": "François-Müller", # Multiple special chars + "date_of_birth": "01/01/1990", + "phone_number": "555-1234", "city": "Montréal", # French accent "province": "Québec", # French accent - "postalCode": "H3A 1A1", + "postal_code": "H3A 1A1", }, "demographics": { - "genderIdentity": "Non-binary", + "gender_identity": "Non-binary", "pronouns": ["they", "them"], - "ethnicGroup": ["Other"], - "ethnicGroupCustom": "中国人 (Chinese) & हिन्दी (Hindi) 🌍", # Unicode mix + "ethnic_group": ["Other"], + "ethnic_group_custom": "中国人 (Chinese) & हिन्दी (Hindi) 🌍", # Unicode mix }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Leucémie (Leukemia)", - "dateOfDiagnosis": "01/01/2020", + "date_of_diagnosis": "01/01/2020", "treatments": ["Chimiothérapie"], "experiences": ["Fatigue"], - "otherTreatment": "Traitement spécialisé avec émojis 💊🏥", - "otherExperience": "Expérience émotionnelle complexe 😔➡️😊", + "other_treatment": "Traitement spécialisé avec émojis 💊🏥", + "other_experience": "Expérience émotionnelle complexe 😔➡️😊", }, } @@ -1311,21 +1311,21 @@ def test_boundary_date_values(db_session, test_user): # Test with very old and very recent dates form_data = { - "formType": "participant", - "hasBloodCancer": "yes", - "caringForSomeone": "no", - "personalInfo": { - "firstName": "Old", - "lastName": "Person", - "dateOfBirth": "01/01/1920", # Very old date - "phoneNumber": "555-1234", + "form_type": "participant", + "has_blood_cancer": "yes", + "caring_for_someone": "no", + "personal_info": { + "first_name": "Old", + "last_name": "Person", + "date_of_birth": "01/01/1920", # Very old date + "phone_number": "555-1234", "city": "Toronto", "province": "Ontario", - "postalCode": "M5V 3A1", + "postal_code": "M5V 3A1", }, - "cancerExperience": { + "cancer_experience": { "diagnosis": "Leukemia", - "dateOfDiagnosis": "31/12/2023", # Very recent date + "date_of_diagnosis": "31/12/2023", # Very recent date "treatments": ["Surgery"], "experiences": ["Fatigue"], }, diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index fd7447d0..643f22ac 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -1,12 +1,13 @@ +import os from uuid import UUID import pytest from fastapi import HTTPException -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker from app.models import Role -from app.models.Base import Base from app.models.User import User from app.schemas.user import ( SignUpMethod, @@ -17,9 +18,14 @@ ) from app.services.implementations.user_service import UserService -# Test DB Configuration -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +# Test DB Configuration - Always require Postgres for full parity +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +if not POSTGRES_DATABASE_URL: + raise RuntimeError( + "POSTGRES_DATABASE_URL is not set. Please export a Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@localhost:5432/llsc" + ) +engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -69,37 +75,44 @@ def delete_user(self, uid): @pytest.fixture(scope="function") def db_session(): - """Provide a clean database session for each test""" - Base.metadata.create_all(bind=engine) + """Provide a clean database session for each test (Postgres only). + + Assumes Alembic migrations have run. Seeds roles if missing. + """ + session = TestingSessionLocal() try: - # Clean up any existing data first - session.query(User).delete() - session.query(Role).delete() + # Clean up any existing data first (ensure no FK violations) + session.execute( + text( + "TRUNCATE TABLE form_submissions, user_loved_one_experiences, user_loved_one_treatments, " + "user_experiences, user_treatments, available_times, matches, suggested_times, user_data, users " + "RESTART IDENTITY CASCADE" + ) + ) session.commit() - # Create test roles - roles = [ + # Ensure roles exist (id 1..3) + existing = {r.id for r in session.query(Role).all()} + seed_roles = [ Role(id=1, name=UserRole.PARTICIPANT), Role(id=2, name=UserRole.VOLUNTEER), Role(id=3, name=UserRole.ADMIN), ] - for role in roles: - session.add(role) - session.commit() + for role in seed_roles: + if role.id not in existing: + try: + session.add(role) + session.commit() + except IntegrityError: + session.rollback() yield session finally: session.rollback() session.close() - # Clean up - session = TestingSessionLocal() - session.query(User).delete() - session.query(Role).delete() - session.commit() - session.close() - Base.metadata.drop_all(bind=engine) + # No DDL teardown for Postgres; database persists across tests @pytest.mark.asyncio diff --git a/docker-compose.yml b/docker-compose.yml index 47a67585..06cbd7c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,8 +23,8 @@ services: depends_on: db: condition: service_healthy - environment: - POSTGRES_DATABASE_URL: ${POSTGRES_DATABASE_URL} + env_file: + - ./.env db: container_name: llsc_db image: postgres:16-alpine diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4b318ad9..66d44a39 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@chakra-ui/react": "^3.13.0", + "axios": "^1.11.0", "firebase": "^11.10.0", "humps": "^2.0.1", "jwt-decode": "^4.0.0", @@ -2859,6 +2860,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2885,6 +2892,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2986,6 +3004,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3151,6 +3182,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3389,6 +3432,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3416,6 +3468,20 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3516,14 +3582,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -3532,7 +3594,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3586,10 +3647,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3599,15 +3659,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -4317,6 +4377,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -4344,6 +4424,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4413,17 +4509,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4432,6 +4532,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -4559,13 +4672,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4631,10 +4743,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4647,7 +4758,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5462,6 +5572,15 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5486,6 +5605,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6227,6 +6367,12 @@ "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/proxy-memoize": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/proxy-memoize/-/proxy-memoize-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d507f94a..b14c3943 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@chakra-ui/react": "^3.13.0", + "axios": "^1.11.0", "firebase": "^11.10.0", "humps": "^2.0.1", "jwt-decode": "^4.0.0", diff --git a/frontend/src/APIClients/baseAPIClient.ts b/frontend/src/APIClients/baseAPIClient.ts index 39c63c07..8fc90298 100644 --- a/frontend/src/APIClients/baseAPIClient.ts +++ b/frontend/src/APIClients/baseAPIClient.ts @@ -2,6 +2,7 @@ import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; // Fix this import import { jwtDecode } from 'jwt-decode'; import { camelizeKeys, decamelizeKeys } from 'humps'; +import { auth } from '@/config/firebase'; import AUTHENTICATED_USER_KEY from '../constants/AuthConstants'; import { setLocalStorageObjProperty } from '../utils/LocalStorageUtils'; @@ -9,8 +10,7 @@ import { setLocalStorageObjProperty } from '../utils/LocalStorageUtils'; import { DecodedJWT } from '../types/authTypes'; const baseAPIClient = axios.create({ - // TODO: Fix this - baseURL: process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000', + baseURL: process.env.REACT_APP_BACKEND_URL || 'http://localhost:8080', }); // Python API uses snake_case, frontend uses camelCase @@ -27,6 +27,17 @@ baseAPIClient.interceptors.response.use((response: AxiosResponse) => { baseAPIClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { const newConfig = { ...config }; + // Inject Firebase ID token if not already present + try { + if (!newConfig.headers.Authorization) { + const user = auth.currentUser; + if (user) { + const idToken = await user.getIdToken(); + newConfig.headers.Authorization = `Bearer ${idToken}`; + } + } + } catch {} + // if access token in header has expired, do a refresh const authHeaderParts = config.headers.Authorization?.toString().split(' '); if ( diff --git a/frontend/src/hooks/useIntakeForm.ts b/frontend/src/hooks/useIntakeForm.ts deleted file mode 100644 index 6bdde816..00000000 --- a/frontend/src/hooks/useIntakeForm.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useForm } from 'react-hook-form'; -import { IntakeFormData, INITIAL_INTAKE_FORM_DATA } from '@/constants/form'; - -export const useIntakeForm = () => { - const form = useForm({ - defaultValues: INITIAL_INTAKE_FORM_DATA, - }); - - const onSubmit = async (data: IntakeFormData) => { - try { - // TODO: Add API call to submit form data - console.log('Form data:', data); - // Show success message - alert('Form submitted successfully'); - } catch (err) { - console.error('Error submitting form:', err); - // Show error message - alert('Error submitting form. Please try again later.'); - } - }; - - return { - ...form, - onSubmit, - }; -}; diff --git a/frontend/src/pages/participant/intake.tsx b/frontend/src/pages/participant/intake.tsx index 80ed76eb..0bff1d50 100644 --- a/frontend/src/pages/participant/intake.tsx +++ b/frontend/src/pages/participant/intake.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, Flex } from '@chakra-ui/react'; +import baseAPIClient from '@/APIClients/baseAPIClient'; import { PersonalInfoForm } from '@/components/intake/personal-info-form'; import { DemographicCancerForm, @@ -56,11 +57,10 @@ export default function ParticipantIntakePage() { ...INITIAL_INTAKE_FORM_DATA, formType: 'participant', }); + const [, setSubmitting] = useState(false); - // Determine flow based on experience type selections - const getFlowSteps = () => { - const { hasBloodCancer, caringForSomeone } = formData; - + const computeFlowSteps = (data: IntakeFormData) => { + const { hasBloodCancer, caringForSomeone } = data; if (hasBloodCancer === 'yes' && caringForSomeone === 'no') { // Flow 1: Participant - Cancer Patient return ['experience-personal', 'demographics-cancer', 'thank-you']; @@ -79,9 +79,28 @@ export default function ParticipantIntakePage() { return ['experience-personal']; }; - const currentFlowSteps = getFlowSteps(); + const currentFlowSteps = useMemo(() => computeFlowSteps(formData), [formData]); const currentStepType = currentFlowSteps[currentStep - 1]; + const advanceAfterUpdate = async (updated: IntakeFormData) => { + const steps = computeFlowSteps(updated); + const nextType = steps[currentStep]; + if (nextType === 'thank-you') { + setSubmitting(true); + try { + console.log('[INTAKE][SUBMIT] About to submit answers', { + currentStep, + nextType, + answers: updated, + }); + await baseAPIClient.post('/intake/submissions', { answers: updated }); + } finally { + setSubmitting(false); + } + } + setCurrentStep((s) => s + 1); + }; + const handleExperiencePersonalSubmit = ( experienceData: ExperienceData, personalData: PersonalData, @@ -96,72 +115,81 @@ export default function ParticipantIntakePage() { }; const handleDemographicsNext = (data: DemographicCancerFormData) => { - setFormData((prev) => ({ - ...prev, - demographics: { - genderIdentity: data.genderIdentity, - pronouns: data.pronouns, - ethnicGroup: data.ethnicGroup, - maritalStatus: data.maritalStatus, - hasKids: data.hasKids, - }, - // Add cancer experience if they have cancer - ...(prev.hasBloodCancer === 'yes' && { - cancerExperience: { - diagnosis: data.diagnosis, - dateOfDiagnosis: data.dateOfDiagnosis, - treatments: data.treatments, - experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + demographics: { + genderIdentity: data.genderIdentity, + pronouns: data.pronouns, + ethnicGroup: data.ethnicGroup, + maritalStatus: data.maritalStatus, + hasKids: data.hasKids, }, - }), - // Add caregiver experience if they're a caregiver without cancer - ...(prev.hasBloodCancer === 'no' && - prev.caringForSomeone === 'yes' && { - caregiverExperience: { + ...(prev.hasBloodCancer === 'yes' && { + cancerExperience: { + diagnosis: data.diagnosis, + dateOfDiagnosis: data.dateOfDiagnosis, + treatments: data.treatments, experiences: data.experiences, + otherTreatment: data.otherTreatment, otherExperience: data.otherExperience, }, }), - })); - setCurrentStep(currentStep + 1); + ...(prev.hasBloodCancer === 'no' && + prev.caringForSomeone === 'yes' && { + caregiverExperience: { + experiences: data.experiences, + otherExperience: data.otherExperience, + }, + }), + } as IntakeFormData; + + void advanceAfterUpdate(updated); + return updated; + }); }; const handleLovedOneNext = (data: LovedOneFormData) => { - setFormData((prev) => ({ - ...prev, - lovedOne: { - demographics: { - genderIdentity: data.genderIdentity, - genderIdentityCustom: data.genderIdentityCustom, - age: data.age, - }, - cancerExperience: { - diagnosis: data.diagnosis, - dateOfDiagnosis: data.dateOfDiagnosis, - treatments: data.treatments, - experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + lovedOne: { + demographics: { + genderIdentity: data.genderIdentity, + genderIdentityCustom: data.genderIdentityCustom, + age: data.age, + }, + cancerExperience: { + diagnosis: data.diagnosis, + dateOfDiagnosis: data.dateOfDiagnosis, + treatments: data.treatments, + experiences: data.experiences, + otherTreatment: data.otherTreatment, + otherExperience: data.otherExperience, + }, }, - }, - })); - setCurrentStep(currentStep + 1); + }; + + void advanceAfterUpdate(updated); + return updated; + }); }; const handleBasicDemographicsNext = (data: BasicDemographicsFormData) => { - setFormData((prev) => ({ - ...prev, - demographics: { - genderIdentity: data.genderIdentity, - pronouns: data.pronouns, - ethnicGroup: data.ethnicGroup, - maritalStatus: data.maritalStatus, - hasKids: data.hasKids, - }, - })); - setCurrentStep(currentStep + 1); + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + demographics: { + genderIdentity: data.genderIdentity, + pronouns: data.pronouns, + ethnicGroup: data.ethnicGroup, + maritalStatus: data.maritalStatus, + hasKids: data.hasKids, + }, + }; + void advanceAfterUpdate(updated); + return updated; + }); }; // If we're on thank you step, show the screen with form data diff --git a/frontend/src/pages/volunteer/intake.tsx b/frontend/src/pages/volunteer/intake.tsx index 1c04f9a1..5143bfa5 100644 --- a/frontend/src/pages/volunteer/intake.tsx +++ b/frontend/src/pages/volunteer/intake.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Box, Flex } from '@chakra-ui/react'; +import baseAPIClient from '@/APIClients/baseAPIClient'; import { PersonalInfoForm } from '@/components/intake/personal-info-form'; import { DemographicCancerForm, @@ -56,11 +57,11 @@ export default function VolunteerIntakePage() { ...INITIAL_INTAKE_FORM_DATA, formType: 'volunteer', }); + const [, setSubmitting] = useState(false); // Determine flow based on experience type selections - const getFlowSteps = () => { - const { hasBloodCancer, caringForSomeone } = formData; - + const computeFlowSteps = (data: IntakeFormData) => { + const { hasBloodCancer, caringForSomeone } = data; if (hasBloodCancer === 'yes' && caringForSomeone === 'no') { // Flow 6: Volunteer - Cancer Patient return ['experience-personal', 'demographics-cancer', 'thank-you']; @@ -79,9 +80,41 @@ export default function VolunteerIntakePage() { return ['experience-personal']; }; - const currentFlowSteps = getFlowSteps(); + const currentFlowSteps = useMemo(() => computeFlowSteps(formData), [formData]); const currentStepType = currentFlowSteps[currentStep - 1]; + useEffect(() => { + // eslint-disable-next-line no-console + console.log('[INTAKE] Volunteer intake page mounted'); + }, []); + + const advanceAfterUpdate = async (updated: IntakeFormData) => { + const steps = computeFlowSteps(updated); + const nextType = steps[currentStep]; + if (nextType === 'thank-you') { + setSubmitting(true); + try { + // eslint-disable-next-line no-console + console.log('[INTAKE][SUBMIT] About to submit answers (volunteer)', { + currentStep, + nextType, + answers: updated, + }); + await baseAPIClient.post('/intake/submissions', { answers: updated }); + } catch (error: any) { + // eslint-disable-next-line no-console + console.error( + '[INTAKE][SUBMIT][ERROR] Volunteer submission failed', + error?.response?.data || error, + ); + return; // Do not advance on failure + } finally { + setSubmitting(false); + } + } + setCurrentStep((s) => s + 1); + }; + const handleExperiencePersonalSubmit = ( experienceData: ExperienceData, personalData: PersonalData, @@ -96,72 +129,81 @@ export default function VolunteerIntakePage() { }; const handleDemographicsNext = (data: DemographicCancerFormData) => { - setFormData((prev) => ({ - ...prev, - demographics: { - genderIdentity: data.genderIdentity, - pronouns: data.pronouns, - ethnicGroup: data.ethnicGroup, - maritalStatus: data.maritalStatus, - hasKids: data.hasKids, - }, - // Add cancer experience if they have cancer - ...(prev.hasBloodCancer === 'yes' && { - cancerExperience: { - diagnosis: data.diagnosis, - dateOfDiagnosis: data.dateOfDiagnosis, - treatments: data.treatments, - experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + demographics: { + genderIdentity: data.genderIdentity, + pronouns: data.pronouns, + ethnicGroup: data.ethnicGroup, + maritalStatus: data.maritalStatus, + hasKids: data.hasKids, }, - }), - // Add caregiver experience if they're a caregiver without cancer - ...(prev.hasBloodCancer === 'no' && - prev.caringForSomeone === 'yes' && { - caregiverExperience: { + ...(prev.hasBloodCancer === 'yes' && { + cancerExperience: { + diagnosis: data.diagnosis, + dateOfDiagnosis: data.dateOfDiagnosis, + treatments: data.treatments, experiences: data.experiences, + otherTreatment: data.otherTreatment, otherExperience: data.otherExperience, }, }), - })); - setCurrentStep(currentStep + 1); + ...(prev.hasBloodCancer === 'no' && + prev.caringForSomeone === 'yes' && { + caregiverExperience: { + experiences: data.experiences, + otherExperience: data.otherExperience, + }, + }), + } as IntakeFormData; + + void advanceAfterUpdate(updated); + return updated; + }); }; const handleLovedOneNext = (data: LovedOneFormData) => { - setFormData((prev) => ({ - ...prev, - lovedOne: { - demographics: { - genderIdentity: data.genderIdentity, - genderIdentityCustom: data.genderIdentityCustom, - age: data.age, - }, - cancerExperience: { - diagnosis: data.diagnosis, - dateOfDiagnosis: data.dateOfDiagnosis, - treatments: data.treatments, - experiences: data.experiences, - otherTreatment: data.otherTreatment, - otherExperience: data.otherExperience, + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + lovedOne: { + demographics: { + genderIdentity: data.genderIdentity, + genderIdentityCustom: data.genderIdentityCustom, + age: data.age, + }, + cancerExperience: { + diagnosis: data.diagnosis, + dateOfDiagnosis: data.dateOfDiagnosis, + treatments: data.treatments, + experiences: data.experiences, + otherTreatment: data.otherTreatment, + otherExperience: data.otherExperience, + }, }, - }, - })); - setCurrentStep(currentStep + 1); + }; + + void advanceAfterUpdate(updated); + return updated; + }); }; const handleBasicDemographicsNext = (data: BasicDemographicsFormData) => { - setFormData((prev) => ({ - ...prev, - demographics: { - genderIdentity: data.genderIdentity, - pronouns: data.pronouns, - ethnicGroup: data.ethnicGroup, - maritalStatus: data.maritalStatus, - hasKids: data.hasKids, - }, - })); - setCurrentStep(currentStep + 1); + setFormData((prev) => { + const updated: IntakeFormData = { + ...prev, + demographics: { + genderIdentity: data.genderIdentity, + pronouns: data.pronouns, + ethnicGroup: data.ethnicGroup, + maritalStatus: data.maritalStatus, + hasKids: data.hasKids, + }, + }; + void advanceAfterUpdate(updated); + return updated; + }); }; // If we're on thank you step, show the screen with form data