From 987986a56ed5ec7b48ff92ee2c2b06d8c0c7aca6 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sat, 11 Oct 2025 19:10:22 -0400 Subject: [PATCH 01/15] update form_status to secondary_application_todo after submitting secondary app --- .gitignore | 5 +- backend/app/routes/volunteer_data.py | 25 +- .../implementations/intake_form_processor.py | 22 +- .../implementations/volunteer_data_service.py | 23 +- .../intake/volunteer-references-form.tsx | 18 +- frontend/src/pages/api/volunteer-data.ts | 74 ---- .../src/pages/api/volunteer-data/submit.ts | 28 -- .../volunteer/secondary-application/index.tsx | 51 ++- frontend/src/pages/volunteer/secondary.tsx | 403 ------------------ 9 files changed, 121 insertions(+), 528 deletions(-) delete mode 100644 frontend/src/pages/api/volunteer-data.ts delete mode 100644 frontend/src/pages/api/volunteer-data/submit.ts delete mode 100644 frontend/src/pages/volunteer/secondary.tsx diff --git a/.gitignore b/.gitignore index 2cdf4b5c..9a9cc63d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ **/*.cache **/*.egg-info **/test.db -.cursor/ \ No newline at end of file +.cursor/ +.claude/ +.codex/ +.mcp.json diff --git a/backend/app/routes/volunteer_data.py b/backend/app/routes/volunteer_data.py index 8a11961a..bc111c62 100644 --- a/backend/app/routes/volunteer_data.py +++ b/backend/app/routes/volunteer_data.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request from app.middleware.auth import has_roles from app.schemas.user import UserRole @@ -9,8 +11,9 @@ VolunteerDataResponse, VolunteerDataUpdateRequest, ) +from app.services.implementations.user_service import UserService from app.services.implementations.volunteer_data_service import VolunteerDataService -from app.utilities.service_utils import get_volunteer_data_service +from app.utilities.service_utils import get_user_service, get_volunteer_data_service router = APIRouter( prefix="/volunteer-data", @@ -18,16 +21,28 @@ ) -# Public endpoint - anyone can submit volunteer data +# Authenticated endpoint - volunteers submit their secondary application data @router.post("/submit", response_model=VolunteerDataResponse) async def submit_volunteer_data( volunteer_data: VolunteerDataPublicSubmission, + request: Request, volunteer_data_service: VolunteerDataService = Depends(get_volunteer_data_service), + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]), ): - """Public endpoint for volunteers to submit their application data""" + """Endpoint for authenticated volunteers to submit their secondary application data""" try: + # Get current user from request state (set by auth middleware) + current_user_auth_id = request.state.user_id + + try: + user_id_str = await user_service.get_user_id_by_auth_id(current_user_auth_id) + user_id = UUID(user_id_str) + except ValueError as err: + raise HTTPException(status_code=404, detail=str(err)) from err + create_request = VolunteerDataCreateRequest( - user_id=None, + user_id=user_id, experience=volunteer_data.experience, references_json=volunteer_data.references_json, additional_comments=volunteer_data.additional_comments, diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 5d32f404..2dd06722 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -39,8 +39,11 @@ def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> Us # Validate required fields first self._validate_required_fields(form_data) - # Get or create UserData + # Get or create UserData and owning User record user_data, is_new = self._get_or_create_user_data(user_id) + owning_user = self.db.query(User).filter(User.id == user_data.user_id).first() + if not owning_user: + raise ValueError(f"User with id {user_id} not found") # Add to session early to avoid relationship warnings if is_new: @@ -49,6 +52,7 @@ def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> Us # Process different sections of the form self._process_personal_info(user_data, form_data.get("personal_info", {})) + self._sync_user_profile_from_personal_info(owning_user, user_data) self._process_demographics(user_data, form_data.get("demographics", {})) self._process_cancer_experience(user_data, form_data.get("cancer_experience", {})) self._process_flow_control(user_data, form_data) @@ -67,14 +71,11 @@ def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> Us 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 + if not user_data.email and owning_user.email: + user_data.email = owning_user.email # Update form status for the owning user without regressing progress - owning_user = self.db.query(User).filter(User.id == user_data.user_id).first() - if owning_user and owning_user.form_status in { + if owning_user.form_status in { FormStatus.INTAKE_TODO, FormStatus.INTAKE_SUBMITTED, }: @@ -170,6 +171,13 @@ def _process_personal_info(self, user_data: UserData, personal_info: Dict[str, A except ValueError: raise ValueError(f"Invalid date format for dateOfBirth: {personal_info.get('date_of_birth')}") + def _sync_user_profile_from_personal_info(self, owning_user: User, user_data: UserData) -> None: + """Update the core user record with personal info captured on the intake form.""" + if user_data.first_name: + owning_user.first_name = user_data.first_name + if user_data.last_name: + owning_user.last_name = user_data.last_name + def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any]): """Process demographic information.""" user_data.gender_identity = self._trim_text(demographics.get("gender_identity")) diff --git a/backend/app/services/implementations/volunteer_data_service.py b/backend/app/services/implementations/volunteer_data_service.py index 41542220..6059c1ba 100644 --- a/backend/app/services/implementations/volunteer_data_service.py +++ b/backend/app/services/implementations/volunteer_data_service.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.interfaces.volunteer_data_service import IVolunteerDataService +from app.models.User import FormStatus, User from app.models.VolunteerData import VolunteerData from app.schemas.volunteer_data import ( VolunteerDataCreateRequest, @@ -22,22 +23,34 @@ def __init__(self, db: Session): async def create_volunteer_data(self, volunteer_data: VolunteerDataCreateRequest) -> VolunteerDataResponse: try: - if volunteer_data.user_id is not None: - existing_data = ( - self.db.query(VolunteerData).filter(VolunteerData.user_id == volunteer_data.user_id).first() - ) + user_id = volunteer_data.user_id + user = None + + # Check if volunteer data already exists for this user + if user_id is not None: + existing_data = self.db.query(VolunteerData).filter(VolunteerData.user_id == user_id).first() if existing_data: raise HTTPException(status_code=409, detail="Volunteer data already exists for this user") + user = self.db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + # Create new volunteer data entry db_volunteer_data = VolunteerData( - user_id=volunteer_data.user_id, + user_id=user_id, experience=volunteer_data.experience, references_json=volunteer_data.references_json, additional_comments=volunteer_data.additional_comments, ) self.db.add(db_volunteer_data) + + # Update the user's form_status if we have a user + if user_id and user and user.form_status == FormStatus.SECONDARY_APPLICATION_TODO: + # Update to SECONDARY_APPLICATION_SUBMITTED when volunteer submits secondary application + user.form_status = FormStatus.SECONDARY_APPLICATION_SUBMITTED + self.db.commit() self.db.refresh(db_volunteer_data) diff --git a/frontend/src/components/intake/volunteer-references-form.tsx b/frontend/src/components/intake/volunteer-references-form.tsx index ccdb73a6..d2a95374 100644 --- a/frontend/src/components/intake/volunteer-references-form.tsx +++ b/frontend/src/components/intake/volunteer-references-form.tsx @@ -20,6 +20,8 @@ interface VolunteerReferencesFormData { interface VolunteerReferencesFormProps { onNext: (data: VolunteerReferencesFormData) => void; onBack?: () => void; + isSubmitting?: boolean; + submitError?: string | null; } const DEFAULT_VALUES: VolunteerReferencesFormData = { @@ -36,7 +38,12 @@ const DEFAULT_VALUES: VolunteerReferencesFormData = { additionalInfo: '', }; -export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesFormProps) { +export function VolunteerReferencesForm({ + onNext, + onBack, + isSubmitting = false, + submitError, +}: VolunteerReferencesFormProps) { const { control, handleSubmit, @@ -439,6 +446,12 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF + {submitError ? ( + + {submitError} + + ) : null} + {/* Navigation Buttons */} {onBack ? ( @@ -468,7 +481,8 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF color="white" _hover={{ bg: COLORS.teal }} _active={{ bg: COLORS.teal }} - disabled={!isValid} + disabled={!isValid || isSubmitting} + loading={isSubmitting} w="auto" h="40px" fontSize="14px" diff --git a/frontend/src/pages/api/volunteer-data.ts b/frontend/src/pages/api/volunteer-data.ts deleted file mode 100644 index 42cc4444..00000000 --- a/frontend/src/pages/api/volunteer-data.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - -const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req; - - try { - let url = `${BACKEND_URL}/volunteer-data`; - let fetchOptions: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - // Forward authorization header if present - ...(req.headers.authorization && { - Authorization: req.headers.authorization, - }), - }, - }; - - // Handle different HTTP methods - switch (method) { - case 'POST': - // Create volunteer data - fetchOptions.body = JSON.stringify(req.body); - break; - - case 'GET': - // Get volunteer data - handle query parameters - if (req.query.id) { - url = `${BACKEND_URL}/volunteer-data/${req.query.id}`; - } else if (req.query.user_id) { - url = `${BACKEND_URL}/volunteer-data/user/${req.query.user_id}`; - } - // If no specific query, it will get all volunteer data - break; - - case 'PUT': - // Update volunteer data - if (req.query.id) { - url = `${BACKEND_URL}/volunteer-data/${req.query.id}`; - fetchOptions.body = JSON.stringify(req.body); - } else { - return res.status(400).json({ error: 'ID required for PUT request' }); - } - break; - - case 'DELETE': - // Delete volunteer data - if (req.query.id) { - url = `${BACKEND_URL}/volunteer-data/${req.query.id}`; - } else { - return res.status(400).json({ error: 'ID required for DELETE request' }); - } - break; - - default: - return res.status(405).json({ error: `Method ${method} not allowed` }); - } - - // Make request to FastAPI backend - const response = await fetch(url, fetchOptions); - const data = await response.json(); - - // Forward the response status and data - res.status(response.status).json(data); - } catch (error) { - console.error('API proxy error:', error); - res.status(500).json({ - error: 'Internal server error', - details: error instanceof Error ? error.message : 'Unknown error', - }); - } -} diff --git a/frontend/src/pages/api/volunteer-data/submit.ts b/frontend/src/pages/api/volunteer-data/submit.ts deleted file mode 100644 index 7a94c808..00000000 --- a/frontend/src/pages/api/volunteer-data/submit.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - -const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - try { - const response = await fetch(`${BACKEND_URL}/volunteer-data/submit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req.body), - }); - - const data = await response.json(); - res.status(response.status).json(data); - } catch (error) { - console.error('API proxy error:', error); - res.status(500).json({ - error: 'Internal server error', - details: error instanceof Error ? error.message : 'Unknown error', - }); - } -} diff --git a/frontend/src/pages/volunteer/secondary-application/index.tsx b/frontend/src/pages/volunteer/secondary-application/index.tsx index f07d3275..e8571de3 100644 --- a/frontend/src/pages/volunteer/secondary-application/index.tsx +++ b/frontend/src/pages/volunteer/secondary-application/index.tsx @@ -9,6 +9,7 @@ import { COLORS } from '@/constants/form'; import { VolunteerProfileForm } from '@/components/intake/volunteer-profile-form'; import { VolunteerReferencesForm } from '@/components/intake/volunteer-references-form'; import { syncCurrentUser } from '@/APIClients/authAPIClient'; +import baseAPIClient from '@/APIClients/baseAPIClient'; export default function SecondaryApplicationPage() { const router = useRouter(); @@ -23,6 +24,36 @@ export default function SecondaryApplicationPage() { reference2: { fullName: '', email: '', phoneNumber: '' }, additionalInfo: '', }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const submitSecondaryApplication = async ( + profile: { experience: string }, + references: { + reference1: { fullName: string; email: string; phoneNumber: string }; + reference2: { fullName: string; email: string; phoneNumber: string }; + additionalInfo: string; + }, + ) => { + const payload = { + experience: profile.experience, + references_json: JSON.stringify([ + { + fullName: references.reference1.fullName, + email: references.reference1.email, + phoneNumber: references.reference1.phoneNumber, + }, + { + fullName: references.reference2.fullName, + email: references.reference2.email, + phoneNumber: references.reference2.phoneNumber, + }, + ]), + additional_comments: references.additionalInfo, + }; + + await baseAPIClient.post('/volunteer-data/submit', payload); + }; const WelcomeScreenStep = () => ( { + setSubmitError(null); + setIsSubmitting(true); setReferencesData(data); - setCurrentStep(4); - await syncCurrentUser(); - await router.replace('/volunteer/secondary-application/thank-you'); + + try { + await submitSecondaryApplication(profileData, data); + await syncCurrentUser(); + await router.replace('/volunteer/secondary-application/thank-you'); + } catch (error) { + const message = + (error as any)?.response?.data?.detail || + (error instanceof Error ? error.message : 'Failed to submit volunteer data'); + setSubmitError(message); + } finally { + setIsSubmitting(false); + } }} onBack={() => setCurrentStep(2)} + isSubmitting={isSubmitting} + submitError={submitError} /> diff --git a/frontend/src/pages/volunteer/secondary.tsx b/frontend/src/pages/volunteer/secondary.tsx deleted file mode 100644 index 76633925..00000000 --- a/frontend/src/pages/volunteer/secondary.tsx +++ /dev/null @@ -1,403 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronRightIcon, CheckCircleIcon, UserIcon } from '@heroicons/react/24/outline'; - -interface Reference { - name: string; - email: string; - phone: string; -} - -export default function VolunteerSecondary() { - const [currentStep, setCurrentStep] = useState(0); - const [experience, setExperience] = useState(''); - const [references, setReferences] = useState([ - { name: '', email: '', phone: '' }, - { name: '', email: '', phone: '' }, - ]); - const [additionalComments, setAdditionalComments] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - const wordCount = - experience.trim() === '' - ? 0 - : experience - .trim() - .split(/\s+/) - .filter((word) => word.length > 0).length; - const MAX_WORDS = 300; - - const handleReferenceChange = (index: number, field: keyof Reference, value: string) => { - const newReferences = [...references]; - newReferences[index][field] = value; - setReferences(newReferences); - }; - - const handleInputFocus = (e: React.FocusEvent) => { - e.target.style.borderColor = '#056067'; - e.target.style.boxShadow = `0 0 0 2px rgba(5, 96, 103, 0.2)`; - }; - - const handleInputBlur = (e: React.FocusEvent) => { - e.target.style.borderColor = 'rgb(209 213 219)'; - e.target.style.boxShadow = 'none'; - }; - - const handleSubmit = async () => { - setIsSubmitting(true); - setError(null); - - try { - const volunteerData = { - experience, - references_json: JSON.stringify(references), - additional_comments: additionalComments, - }; - - const response = await fetch('/api/volunteer-data/submit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(volunteerData), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to submit volunteer data'); - } - - const result = await response.json(); - console.log('Volunteer data submitted successfully:', result); - setCurrentStep(4); // Go to success page - } catch (err) { - console.error('Error submitting volunteer data:', err); - setError(err instanceof Error ? err.message : 'Failed to submit data'); - } finally { - setIsSubmitting(false); - } - }; - - // Step 0: Setup Introduction - if (currentStep === 0) { - return ( -
-
-
- - {/* Checkmark overlay */} -
- - - -
-
- -

- Let's setup your public volunteer profile -

- -

- Your experience provided in this form will -
- be shared with potential matches. -

- - -
-
- ); - } - - // Step 1: Experience Form - if (currentStep === 1) { - return ( -
-
-

Volunteer Profile Form

- - {/* Progress Bar */} -
-
-
-
-
-
- -
-
-

Your Experience

-

- This information will serve as your biography to be shared with potential matches. -

-
- -
- -