Skip to content

Commit eaca682

Browse files
authored
Wire up intake frontend to backend (#50)
This PR completes the intake forms end-to-end. Once the user creates an account and logs in they are taken to the participant or volunteer intake forms depending their role. Once they fill out the intake form, data is sent to the backend, processed, and stored in the db tables. Things to do next / questions: - what should we do after the intake form is submitted (like what should show up next in the UI) - right now after they log in they will just see the intake form again - we must complete the rankings forms --> this requires mostly backend but also some frontend like making sure some of the options are fetched from the backend (for the experiences) rather than hardcoded. I will rebase our rankings branch onto this one and work on this <!-- - our db migrations for the intake not good. i will redo that part and also ensure things are seeded properly --> - also should have some frontend gating by role - eg. if i am a volunteer i can still go to the /participant/intake form and fill that out. we should block this - i don't think we've done the secondary screening for volunteers yet so that should be done (we can probably do this during the fall term) ## Notion ticket link <!-- Please replace with your ticket's URL --> [Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR
1 parent fe7c83e commit eaca682

File tree

16 files changed

+845
-592
lines changed

16 files changed

+845
-592
lines changed

.github/workflows/backend-ci.yml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ on:
1515
jobs:
1616
test:
1717
runs-on: ubuntu-latest
18+
env:
19+
POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test
1820

1921
strategy:
2022
matrix:
@@ -70,7 +72,7 @@ jobs:
7072
- name: Run database migrations
7173
working-directory: ./backend
7274
run: |
73-
pdm run alembic upgrade head
75+
pdm run alembic upgrade heads
7476
7577
- name: Run linting
7678
working-directory: ./backend
@@ -89,10 +91,7 @@ jobs:
8991
run: |
9092
pdm run python -m pytest tests/unit/ -v --cov=app --cov-report=xml --cov-report=term-missing
9193
92-
- name: Run integration tests
93-
working-directory: ./backend
94-
run: |
95-
pdm run python -m pytest tests/functional/ -v
94+
# Skipping functional tests for now; no active tests in tests/functional
9695

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

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

121123
services:
122124
postgres:
@@ -163,7 +165,7 @@ jobs:
163165
- name: Run database migrations
164166
working-directory: ./backend
165167
run: |
166-
pdm run alembic upgrade head
168+
pdm run alembic upgrade heads
167169
168170
- name: Start backend server
169171
working-directory: ./backend

backend/app/middleware/auth_middleware.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ def is_public_path(self, path: str) -> bool:
2727
return False
2828

2929
async def dispatch(self, request: Request, call_next):
30+
# Allow preflight CORS requests to pass through without auth
31+
if request.method.upper() == "OPTIONS":
32+
return await call_next(request)
33+
3034
if self.is_public_path(request.url.path):
3135
self.logger.info(f"Skipping auth for public path: {request.url.path}")
3236
return await call_next(request)

backend/app/routes/intake.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,23 +113,24 @@ async def create_form_submission(
113113
if not current_user:
114114
raise HTTPException(status_code=401, detail="User not found")
115115

116-
# Determine form_id if not provided
116+
# Determine form_id if not provided and derive effective form_type for auth check
117117
form_id = submission.form_id
118+
effective_form_type = None
118119
if not form_id:
119-
# Auto-detect form based on formType in answers
120-
form_type = submission.answers.get("formType")
121-
if not form_type:
120+
effective_form_type = submission.answers.get("form_type")
121+
if not effective_form_type:
122122
raise HTTPException(
123-
status_code=400, detail="formType must be specified in answers when form_id is not provided"
123+
status_code=400, detail="form_type must be specified in answers when form_id is not provided"
124124
)
125125

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

129-
form_name = form_name_mapping.get(form_type)
129+
form_name = form_name_mapping.get(effective_form_type)
130130
if not form_name:
131131
raise HTTPException(
132-
status_code=400, detail=f"Invalid formType: {form_type}. Must be 'participant' or 'volunteer'"
132+
status_code=400,
133+
detail=f"Invalid formType: {effective_form_type}. Must be 'participant' or 'volunteer'",
133134
)
134135

135136
# Find the form
@@ -138,11 +139,23 @@ async def create_form_submission(
138139
if not form:
139140
raise HTTPException(status_code=500, detail=f"Intake form '{form_name}' not found in database")
140141
form_id = form.id
141-
142-
# Verify the form exists and is of type 'intake'
143-
form = db.query(Form).filter(Form.id == form_id, Form.type == "intake").first()
144-
if not form:
145-
raise HTTPException(status_code=404, detail="Intake form not found")
142+
else:
143+
# Verify the form exists and is of type 'intake'
144+
form = db.query(Form).filter(Form.id == form_id, Form.type == "intake").first()
145+
if not form:
146+
raise HTTPException(status_code=404, detail="Intake form not found")
147+
# Derive effective type from form name
148+
if "Participant" in form.name:
149+
effective_form_type = "participant"
150+
elif "Volunteer" in form.name:
151+
effective_form_type = "volunteer"
152+
153+
# Enforce role-to-form access (admin exempt)
154+
if current_user.role.name != "admin":
155+
if current_user.role.name == "volunteer" and effective_form_type != "volunteer":
156+
raise HTTPException(status_code=403, detail="Volunteers can only submit the volunteer intake form")
157+
if current_user.role.name == "participant" and effective_form_type != "participant":
158+
raise HTTPException(status_code=403, detail="Participants can only submit the participant intake form")
146159

147160
# Create the raw form submission record
148161
db_submission = FormSubmission(

backend/app/schemas/user.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ class UserUpdateRequest(BaseModel):
7878
Request schema for user updates, all fields optional
7979
"""
8080

81-
first_name: Optional[str] = Field(None, min_length=1, max_length=50)
82-
last_name: Optional[str] = Field(None, min_length=1, max_length=50)
81+
first_name: Optional[str] = Field(None, min_length=0, max_length=50)
82+
last_name: Optional[str] = Field(None, min_length=0, max_length=50)
8383
email: Optional[EmailStr] = None
8484
role: Optional[UserRole] = None
8585
approved: Optional[bool] = None
@@ -91,8 +91,8 @@ class UserCreateResponse(BaseModel):
9191
"""
9292

9393
id: UUID
94-
first_name: str
95-
last_name: str
94+
first_name: Optional[str]
95+
last_name: Optional[str]
9696
email: EmailStr
9797
role_id: int
9898
auth_id: str
@@ -108,8 +108,8 @@ class UserResponse(BaseModel):
108108
"""
109109

110110
id: UUID
111-
first_name: str
112-
last_name: str
111+
first_name: Optional[str]
112+
last_name: Optional[str]
113113
email: EmailStr
114114
role_id: int
115115
auth_id: str

backend/app/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ async def lifespan(_: FastAPI):
4545
# Source: https://stackoverflow.com/questions/77170361/
4646
# running-alembic-migrations-on-fastapi-startup
4747
app = FastAPI(lifespan=lifespan)
48+
49+
app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS)
4850
app.add_middleware(
4951
CORSMiddleware,
5052
allow_origins=[
@@ -60,8 +62,6 @@ async def lifespan(_: FastAPI):
6062
allow_methods=["*"],
6163
allow_headers=["*"],
6264
)
63-
64-
app.add_middleware(AuthMiddleware, public_paths=PUBLIC_PATHS)
6565
app.include_router(auth.router)
6666
app.include_router(user.router)
6767
app.include_router(availability.router)

backend/app/services/implementations/intake_form_processor.py

Lines changed: 67 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from sqlalchemy.orm import Session
77

8-
from app.models import Experience, Treatment, UserData
8+
from app.models import Experience, Treatment, User, UserData
99

1010
logger = logging.getLogger(__name__)
1111

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

5050
# Process different sections of the form
51-
self._process_personal_info(user_data, form_data.get("personalInfo", {}))
51+
self._process_personal_info(user_data, form_data.get("personal_info", {}))
5252
self._process_demographics(user_data, form_data.get("demographics", {}))
53-
self._process_cancer_experience(user_data, form_data.get("cancerExperience", {}))
53+
self._process_cancer_experience(user_data, form_data.get("cancer_experience", {}))
5454
self._process_flow_control(user_data, form_data)
5555

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

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

6465
# Process loved one data if present
65-
if "lovedOne" in form_data:
66-
self._process_loved_one_data(user_data, form_data["lovedOne"])
66+
if "loved_one" in form_data:
67+
self._process_loved_one_data(user_data, form_data.get("loved_one", {}))
68+
69+
# Fallback: ensure email is set from the authenticated User if not provided in form
70+
if not user_data.email:
71+
owning_user = self.db.query(User).filter(User.id == user_data.user_id).first()
72+
if owning_user and owning_user.email:
73+
user_data.email = owning_user.email
6774

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

98105
def _validate_required_fields(self, form_data: Dict[str, Any]):
99106
"""Validate that required fields are present."""
100-
if "personalInfo" not in form_data:
101-
raise KeyError("personalInfo section is required")
102-
103-
personal_info = form_data["personalInfo"]
104-
required_fields = ["firstName", "lastName", "dateOfBirth", "phoneNumber", "city", "province", "postalCode"]
105-
106-
for field in required_fields:
107-
if field not in personal_info:
108-
raise KeyError(f"Required field missing: personalInfo.{field}")
107+
personal_info = form_data.get("personal_info")
108+
if not personal_info:
109+
raise KeyError("personal_info section is required")
110+
111+
required_fields = [
112+
"first_name",
113+
"last_name",
114+
"date_of_birth",
115+
"phone_number",
116+
"city",
117+
"province",
118+
"postal_code",
119+
]
120+
121+
for key in required_fields:
122+
if key not in personal_info:
123+
raise KeyError(f"Required field missing: personal_info.{key}")
109124

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

133148
def _process_personal_info(self, user_data: UserData, personal_info: Dict[str, Any]):
134149
"""Process personal information fields."""
135-
user_data.first_name = self._trim_text(personal_info.get("firstName"))
136-
user_data.last_name = self._trim_text(personal_info.get("lastName"))
150+
user_data.first_name = self._trim_text(personal_info.get("first_name"))
151+
user_data.last_name = self._trim_text(personal_info.get("last_name"))
137152
user_data.email = self._trim_text(personal_info.get("email"))
138-
user_data.phone = self._trim_text(personal_info.get("phoneNumber"))
153+
user_data.phone = self._trim_text(personal_info.get("phone_number"))
139154
user_data.city = self._trim_text(personal_info.get("city"))
140155
user_data.province = self._trim_text(personal_info.get("province"))
141-
user_data.postal_code = self._trim_text(personal_info.get("postalCode"))
156+
user_data.postal_code = self._trim_text(personal_info.get("postal_code"))
142157

143158
# Parse date of birth with strict validation
144-
if "dateOfBirth" in personal_info:
159+
if "date_of_birth" in personal_info:
145160
try:
146-
user_data.date_of_birth = self._parse_date(personal_info["dateOfBirth"])
161+
user_data.date_of_birth = self._parse_date(personal_info.get("date_of_birth"))
147162
except ValueError:
148-
raise ValueError(f"Invalid date format for dateOfBirth: {personal_info['dateOfBirth']}")
163+
raise ValueError(f"Invalid date format for dateOfBirth: {personal_info.get('date_of_birth')}")
149164

150165
def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any]):
151166
"""Process demographic information."""
152-
user_data.gender_identity = self._trim_text(demographics.get("genderIdentity"))
167+
user_data.gender_identity = self._trim_text(demographics.get("gender_identity"))
153168
user_data.pronouns = demographics.get("pronouns", [])
154-
user_data.ethnic_group = demographics.get("ethnicGroup", [])
155-
user_data.marital_status = self._trim_text(demographics.get("maritalStatus"))
156-
user_data.has_kids = demographics.get("hasKids")
157-
user_data.other_ethnic_group = self._trim_text(demographics.get("ethnicGroupCustom"))
158-
user_data.gender_identity_custom = self._trim_text(demographics.get("genderIdentityCustom"))
169+
user_data.ethnic_group = demographics.get("ethnic_group", [])
170+
user_data.marital_status = self._trim_text(demographics.get("marital_status"))
171+
user_data.has_kids = demographics.get("has_kids")
172+
user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom"))
173+
user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom"))
159174

160175
def _process_cancer_experience(self, user_data: UserData, cancer_experience: Dict[str, Any]):
161176
"""Process cancer experience information."""
162177
user_data.diagnosis = self._trim_text(cancer_experience.get("diagnosis"))
163-
user_data.other_treatment = self._trim_text(cancer_experience.get("otherTreatment"))
164-
user_data.other_experience = self._trim_text(cancer_experience.get("otherExperience"))
178+
user_data.other_treatment = self._trim_text(cancer_experience.get("other_treatment"))
179+
user_data.other_experience = self._trim_text(cancer_experience.get("other_experience"))
165180

166181
# Parse diagnosis date with strict validation
167-
if "dateOfDiagnosis" in cancer_experience:
182+
if "date_of_diagnosis" in cancer_experience:
168183
try:
169-
user_data.date_of_diagnosis = self._parse_date(cancer_experience["dateOfDiagnosis"])
184+
user_data.date_of_diagnosis = self._parse_date(cancer_experience.get("date_of_diagnosis"))
170185
except ValueError:
171-
raise ValueError(f"Invalid date format for dateOfDiagnosis: {cancer_experience['dateOfDiagnosis']}")
186+
raise ValueError(
187+
f"Invalid date format for dateOfDiagnosis: {cancer_experience.get('date_of_diagnosis')}"
188+
)
172189

173190
def _process_flow_control(self, user_data: UserData, form_data: Dict[str, Any]):
174191
"""Process flow control fields."""
175-
user_data.has_blood_cancer = form_data.get("hasBloodCancer")
176-
user_data.caring_for_someone = form_data.get("caringForSomeone")
192+
user_data.has_blood_cancer = form_data.get("has_blood_cancer")
193+
user_data.caring_for_someone = form_data.get("caring_for_someone")
177194

178195
def _process_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]):
179196
"""
@@ -242,7 +259,7 @@ def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict
242259
return
243260

244261
# Handle "Other" caregiver experience text
245-
user_data.other_experience = caregiver_exp.get("otherExperience")
262+
user_data.other_experience = caregiver_exp.get("other_experience")
246263

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

282299
# Process loved one cancer experience
283-
self._process_loved_one_cancer_experience(user_data, loved_one_data.get("cancerExperience", {}))
300+
self._process_loved_one_cancer_experience(user_data, loved_one_data.get("cancer_experience", {}))
284301

285302
# Process loved one treatments and experiences
286-
self._process_loved_one_treatments(user_data, loved_one_data.get("cancerExperience", {}))
287-
self._process_loved_one_experiences(user_data, loved_one_data.get("cancerExperience", {}))
303+
self._process_loved_one_treatments(user_data, loved_one_data.get("cancer_experience", {}))
304+
self._process_loved_one_experiences(user_data, loved_one_data.get("cancer_experience", {}))
288305

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

294-
user_data.loved_one_gender_identity = demographics.get("genderIdentity")
311+
user_data.loved_one_gender_identity = demographics.get("gender_identity")
295312
user_data.loved_one_age = demographics.get("age")
296313

297314
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:
302319
user_data.loved_one_diagnosis = self._trim_text(cancer_exp.get("diagnosis"))
303320

304321
# Parse loved one diagnosis date with strict validation
305-
if "dateOfDiagnosis" in cancer_exp:
322+
if "date_of_diagnosis" in cancer_exp:
306323
try:
307-
user_data.loved_one_date_of_diagnosis = self._parse_date(cancer_exp["dateOfDiagnosis"])
324+
user_data.loved_one_date_of_diagnosis = self._parse_date(cancer_exp.get("date_of_diagnosis"))
308325
except ValueError:
309-
raise ValueError(f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp['dateOfDiagnosis']}")
326+
raise ValueError(
327+
f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp.get('date_of_diagnosis')}"
328+
)
310329

311330
# Handle "Other" treatment and experience text for loved one with trimming
312-
user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("otherTreatment"))
313-
user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("otherExperience"))
331+
user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("other_treatment"))
332+
user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("other_experience"))
314333

315334
def _process_loved_one_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]):
316335
"""Process loved one treatments - map frontend names to database records."""

backend/app/services/implementations/user_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse:
4040

4141
# Create user in database
4242
db_user = User(
43-
first_name=user.first_name,
44-
last_name=user.last_name,
43+
first_name=user.first_name or "",
44+
last_name=user.last_name or "",
4545
email=user.email,
4646
role_id=role_id,
4747
auth_id=firebase_user.uid,

0 commit comments

Comments
 (0)