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/.gitignore b/backend/.gitignore index b70928a3..ce4ae41f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -61,6 +61,9 @@ local_settings.py db.sqlite3 db.sqlite3-journal +# Test databases +*.db + # Flask stuff: instance/ .webassets-cache diff --git a/backend/README.md b/backend/README.md index ae286e24..ea765827 100644 --- a/backend/README.md +++ b/backend/README.md @@ -152,6 +152,18 @@ To apply the migration, run the following command: pdm run alembic upgrade head ``` +## Testing + +### First Time Setup +```bash +pdm run test-setup # Creates test database, runs migrations, seeds data +``` + +### Run Tests +```bash +pdm run tests +``` + ### Logging To add a logger to a new service or file, use the `LOGGER_NAME` function in `app/utilities/constants.py` diff --git a/backend/app/models/Match.py b/backend/app/models/Match.py index 85b59744..e8a6e0bc 100644 --- a/backend/app/models/Match.py +++ b/backend/app/models/Match.py @@ -21,6 +21,7 @@ class Match(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + deleted_at = Column(DateTime(timezone=True), nullable=True) match_status = relationship("MatchStatus") diff --git a/backend/app/models/Task.py b/backend/app/models/Task.py index 9de664f4..11b9ece4 100644 --- a/backend/app/models/Task.py +++ b/backend/app/models/Task.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum as PyEnum -from sqlalchemy import Column, DateTime, ForeignKey +from sqlalchemy import Column, DateTime, ForeignKey, Text from sqlalchemy import Enum as SQLEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -69,6 +69,7 @@ class Task(Base): end_date = Column(DateTime, nullable=True) created_at = Column(DateTime, nullable=False, default=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + description = Column(Text, nullable=True) # Relationships participant = relationship("User", foreign_keys=[participant_id], backref="participant_tasks") diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 1a57d8ce..ca853807 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -30,6 +30,7 @@ class User(Base): auth_id = Column(Text, nullable=False) approved = Column(Boolean, default=False) active = Column(Boolean, nullable=False, default=True) + pending_volunteer_request = Column(Boolean, nullable=False, default=False) form_status = Column( SQLEnum( FormStatus, @@ -51,3 +52,5 @@ class User(Base): volunteer_matches = relationship("Match", back_populates="volunteer", foreign_keys=[Match.volunteer_id]) volunteer_data = relationship("VolunteerData", back_populates="user", uselist=False) + + user_data = relationship("UserData", back_populates="user", uselist=False) diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py index ee8d17f4..d66cc0f8 100644 --- a/backend/app/models/UserData.py +++ b/backend/app/models/UserData.py @@ -87,3 +87,6 @@ class UserData(Base): # Loved one many-to-many relationships loved_one_treatments = relationship("Treatment", secondary=user_loved_one_treatments) loved_one_experiences = relationship("Experience", secondary=user_loved_one_experiences) + + # Back-reference to User + user = relationship("User", back_populates="user_data") diff --git a/backend/app/routes/match.py b/backend/app/routes/match.py index ca984af7..2ceeff7b 100644 --- a/backend/app/routes/match.py +++ b/backend/app/routes/match.py @@ -1,9 +1,30 @@ -from fastapi import APIRouter, Depends, HTTPException +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session -from app.schemas.match import SubmitMatchRequest, SubmitMatchResponse +from app.middleware.auth import has_roles +from app.schemas.match import ( + MatchCreateRequest, + MatchCreateResponse, + MatchDetailResponse, + MatchListForVolunteerResponse, + MatchListResponse, + MatchRequestNewTimesRequest, + MatchRequestNewVolunteersRequest, + MatchRequestNewVolunteersResponse, + MatchResponse, + MatchScheduleRequest, + MatchUpdateRequest, +) +from app.schemas.task import TaskCreateRequest, TaskType +from app.schemas.user import UserRole from app.services.implementations.match_service import MatchService +from app.services.implementations.task_service import TaskService +from app.services.implementations.user_service import UserService from app.utilities.db_utils import get_db +from app.utilities.service_utils import get_task_service, get_user_service router = APIRouter(prefix="/matches", tags=["matches"]) @@ -12,16 +33,256 @@ def get_match_service(db: Session = Depends(get_db)) -> MatchService: return MatchService(db) -@router.post("/confirm-time", response_model=SubmitMatchResponse) -async def confirm_time( - payload: SubmitMatchRequest, +@router.post("/", response_model=MatchCreateResponse) +async def create_matches( + payload: MatchCreateRequest, + match_service: MatchService = Depends(get_match_service), + _authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + return await match_service.create_matches(payload) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{match_id}", response_model=MatchResponse) +async def update_match( + match_id: int, + payload: MatchUpdateRequest, + match_service: MatchService = Depends(get_match_service), + _authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + return await match_service.update_match(match_id, payload) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/me", response_model=MatchListResponse) +async def get_my_matches( + request: Request, match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), ): try: - confirmed_match = await match_service.submit_time(payload) - return confirmed_match + auth_id = getattr(request.state, "user_id", None) + if not auth_id: + raise HTTPException(status_code=401, detail="Unauthorized") + + participant_id_str = await user_service.get_user_id_by_auth_id(auth_id) + participant_id = UUID(participant_id_str) + return await match_service.get_matches_for_participant(participant_id) except HTTPException as http_ex: raise http_ex except Exception as e: - print(e) raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/participant/{participant_id}", response_model=MatchListResponse) +async def get_matches_for_participant( + participant_id: UUID, + match_service: MatchService = Depends(get_match_service), + _authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + return await match_service.get_matches_for_participant(participant_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{match_id}/schedule", response_model=MatchDetailResponse) +async def schedule_match( + match_id: int, + payload: MatchScheduleRequest, + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +): + try: + acting_participant_id = await _resolve_acting_participant_id(request, user_service) + return await match_service.schedule_match(match_id, payload.time_block_id, acting_participant_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{match_id}/request-new-times", response_model=MatchDetailResponse) +async def request_new_times( + match_id: int, + payload: MatchRequestNewTimesRequest, + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +): + try: + acting_participant_id = await _resolve_acting_participant_id(request, user_service) + return await match_service.request_new_times(match_id, payload.suggested_new_times, acting_participant_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{match_id}/cancel", response_model=MatchDetailResponse) +async def cancel_match_as_participant( + match_id: int, + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +): + try: + acting_participant_id = await _resolve_acting_participant_id(request, user_service) + return await match_service.cancel_match_by_participant(match_id, acting_participant_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/volunteer/me", response_model=MatchListForVolunteerResponse) +async def get_my_matches_as_volunteer( + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]), +): + """Get all matches for the current volunteer, including those awaiting acceptance.""" + try: + auth_id = getattr(request.state, "user_id", None) + if not auth_id: + raise HTTPException(status_code=401, detail="Unauthorized") + + volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id) + volunteer_id = UUID(volunteer_id_str) + return await match_service.get_matches_for_volunteer(volunteer_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{match_id}/accept-volunteer", response_model=MatchDetailResponse) +async def accept_match_as_volunteer( + match_id: int, + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]), +): + """Volunteer accepts a match and sends their general availability to participant.""" + try: + acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service) + return await match_service.volunteer_accept_match(match_id, acting_volunteer_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{match_id}/cancel-volunteer", response_model=MatchDetailResponse) +async def cancel_match_as_volunteer( + match_id: int, + request: Request, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + _authorized: bool = has_roles([UserRole.ADMIN, UserRole.VOLUNTEER]), +): + try: + acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service) + return await match_service.cancel_match_by_volunteer(match_id, acting_volunteer_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/request-new-volunteers", response_model=MatchRequestNewVolunteersResponse) +async def request_new_volunteers( + request: Request, + payload: MatchRequestNewVolunteersRequest, + match_service: MatchService = Depends(get_match_service), + user_service: UserService = Depends(get_user_service), + task_service: TaskService = Depends(get_task_service), + _authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +): + try: + participant_id = payload.participant_id + + if participant_id is None: + participant_id = await _resolve_acting_participant_id(request, user_service) + if not participant_id: + raise HTTPException(status_code=400, detail="Participant identity required") + response = await match_service.request_new_volunteers(participant_id, participant_id) + else: + acting_participant_id = await _resolve_acting_participant_id(request, user_service) + response = await match_service.request_new_volunteers(participant_id, acting_participant_id) + task_request = TaskCreateRequest( + participant_id=participant_id, + type=TaskType.MATCHING, + description=payload.message, + ) + await task_service.create_task(task_request) + + return response + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def _resolve_acting_participant_id(request: Request, user_service: UserService) -> Optional[UUID]: + auth_id = getattr(request.state, "user_id", None) + if not auth_id: + raise HTTPException(status_code=401, detail="Authentication required") + + try: + role_name = user_service.get_user_role_by_auth_id(auth_id) + except ValueError as exc: + raise HTTPException(status_code=401, detail="User not found") from exc + + if role_name == UserRole.PARTICIPANT.value: + try: + participant_id_str = await user_service.get_user_id_by_auth_id(auth_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail="Participant not found") from exc + return UUID(participant_id_str) + + if role_name == UserRole.ADMIN.value: + # Admin callers bypass ownership checks + return None + + raise HTTPException(status_code=403, detail="Insufficient role for participant operation") + + +async def _resolve_acting_volunteer_id(request: Request, user_service: UserService) -> Optional[UUID]: + auth_id = getattr(request.state, "user_id", None) + if not auth_id: + raise HTTPException(status_code=401, detail="Authentication required") + + try: + role_name = user_service.get_user_role_by_auth_id(auth_id) + except ValueError as exc: + raise HTTPException(status_code=401, detail="User not found") from exc + + if role_name == UserRole.VOLUNTEER.value: + try: + volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail="Volunteer not found") from exc + return UUID(volunteer_id_str) + + if role_name == UserRole.ADMIN.value: + return None + + raise HTTPException(status_code=403, detail="Insufficient role for volunteer operation") 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/schemas/match.py b/backend/app/schemas/match.py index f856c943..f8dc26d5 100644 --- a/backend/app/schemas/match.py +++ b/backend/app/schemas/match.py @@ -1,13 +1,110 @@ -from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional +from uuid import UUID -from app.schemas.time_block import TimeBlockEntity +from pydantic import BaseModel, ConfigDict, Field +from app.schemas.time_block import TimeBlockEntity, TimeRange -class SubmitMatchRequest(BaseModel): - match_id: int + +class MatchResponse(BaseModel): + id: int + participant_id: UUID + volunteer_id: UUID + match_status: str + chosen_time_block_id: Optional[int] = None + created_at: datetime + updated_at: Optional[datetime] = None + + +class MatchCreateRequest(BaseModel): + participant_id: UUID + volunteer_ids: List[UUID] = Field(..., min_length=1) + match_status: Optional[str] = None + + +class MatchCreateResponse(BaseModel): + matches: List[MatchResponse] + + +class MatchUpdateRequest(BaseModel): + volunteer_id: Optional[UUID] = None + match_status: Optional[str] = None + chosen_time_block_id: Optional[int] = None + clear_chosen_time: bool = False + + +class MatchScheduleRequest(BaseModel): time_block_id: int -class SubmitMatchResponse(BaseModel): - match_id: int - time_block: TimeBlockEntity +class MatchRequestNewTimesRequest(BaseModel): + suggested_new_times: List[TimeRange] = Field(..., min_length=1) + + +class MatchVolunteerSummary(BaseModel): + id: UUID + first_name: Optional[str] = None + last_name: Optional[str] = None + email: str + pronouns: Optional[List[str]] = None + diagnosis: Optional[str] = None + age: Optional[int] = None + treatments: List[str] = Field(default_factory=list) + experiences: List[str] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class MatchParticipantSummary(BaseModel): + id: UUID + first_name: Optional[str] = None + last_name: Optional[str] = None + email: str + pronouns: Optional[List[str]] = None + diagnosis: Optional[str] = None + age: Optional[int] = None + treatments: List[str] = Field(default_factory=list) + experiences: List[str] = Field(default_factory=list) + timezone: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class MatchDetailResponse(BaseModel): + id: int + participant_id: UUID + volunteer: MatchVolunteerSummary + match_status: str + chosen_time_block: Optional[TimeBlockEntity] = None + suggested_time_blocks: List[TimeBlockEntity] + created_at: datetime + updated_at: Optional[datetime] = None + + +class MatchListResponse(BaseModel): + matches: List[MatchDetailResponse] + has_pending_request: bool + + +class MatchDetailForVolunteerResponse(BaseModel): + id: int + participant_id: UUID + volunteer_id: UUID + participant: MatchParticipantSummary + match_status: str + created_at: datetime + updated_at: Optional[datetime] = None + + +class MatchListForVolunteerResponse(BaseModel): + matches: List[MatchDetailForVolunteerResponse] + + +class MatchRequestNewVolunteersResponse(BaseModel): + deleted_matches: int + + +class MatchRequestNewVolunteersRequest(BaseModel): + participant_id: Optional[UUID] = None + message: Optional[str] = None diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 18eb13bb..8465a3a6 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -70,6 +70,7 @@ class TaskCreateRequest(BaseModel): assignee_id: Optional[UUID] = None start_date: Optional[datetime] = None end_date: Optional[datetime] = None + description: Optional[str] = None class TaskUpdateRequest(BaseModel): @@ -84,6 +85,7 @@ class TaskUpdateRequest(BaseModel): assignee_id: Optional[UUID] = None start_date: Optional[datetime] = None end_date: Optional[datetime] = None + description: Optional[str] = None class TaskAssignRequest(BaseModel): @@ -109,6 +111,7 @@ class TaskResponse(BaseModel): end_date: Optional[datetime] created_at: datetime updated_at: datetime + description: Optional[str] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/time_block.py b/backend/app/schemas/time_block.py index c05d8e09..838d375b 100644 --- a/backend/app/schemas/time_block.py +++ b/backend/app/schemas/time_block.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from pydantic import BaseModel, ConfigDict, model_validator @@ -12,10 +12,20 @@ class TimeRange(BaseModel): def check_times(self): if self.end_time <= self.start_time: raise ValueError("end_time must be after start_time") - if self.start_time.minute != 0 or self.end_time.minute != 0: - raise ValueError("Times must be on the hour") + if not self._is_half_hour(self.start_time) or not self._is_half_hour(self.end_time): + raise ValueError("Times must start on the hour or half hour") + + # Validate minimum duration of 30 minutes + duration = self.end_time - self.start_time + if duration < timedelta(minutes=30): + raise ValueError("Time range must be at least 30 minutes") + return self + @staticmethod + def _is_half_hour(value: datetime) -> bool: + return value.minute in {0, 30} and value.second == 0 and value.microsecond == 0 + class TimeBlockBase(BaseModel): start_time: datetime diff --git a/backend/app/seeds/match_status.py b/backend/app/seeds/match_status.py index aee45eb1..ffa7be05 100644 --- a/backend/app/seeds/match_status.py +++ b/backend/app/seeds/match_status.py @@ -11,10 +11,14 @@ def seed_match_status(session: Session) -> None: match_status_data = [ {"id": 1, "name": "pending"}, {"id": 2, "name": "confirmed"}, - {"id": 3, "name": "cancelled"}, + {"id": 3, "name": "cancelled_by_participant"}, {"id": 4, "name": "completed"}, {"id": 5, "name": "no_show"}, {"id": 6, "name": "rescheduled"}, + {"id": 7, "name": "cancelled_by_volunteer"}, + {"id": 8, "name": "requesting_new_times"}, + {"id": 9, "name": "requesting_new_volunteers"}, + {"id": 10, "name": "awaiting_volunteer_acceptance"}, ] for status_data in match_status_data: @@ -25,6 +29,14 @@ def seed_match_status(session: Session) -> None: session.add(status) print(f"Added match status: {status_data['name']}") else: - print(f"Match status already exists: {status_data['name']}") + if existing_status.name != status_data["name"]: + existing_status.name = status_data["name"] + print( + "Updated match status id {status_id} name to {name}".format( + status_id=status_data["id"], name=status_data["name"] + ) + ) + else: + print(f"Match status already exists: {status_data['name']}") session.commit() diff --git a/backend/app/seeds/runner.py b/backend/app/seeds/runner.py index a43c8470..c880e577 100644 --- a/backend/app/seeds/runner.py +++ b/backend/app/seeds/runner.py @@ -14,6 +14,7 @@ # Import all seed functions from .experiences import seed_experiences from .forms import seed_forms +from .match_status import seed_match_status from .qualities import seed_qualities from .ranking_preferences import seed_ranking_preferences from .roles import seed_roles @@ -59,6 +60,7 @@ def seed_database(verbose: bool = True) -> None: ("Forms", seed_forms), ("Users", seed_users), ("Ranking Preferences", seed_ranking_preferences), + ("Match Status", seed_match_status), ] for name, seed_func in seed_functions: 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/match_service.py b/backend/app/services/implementations/match_service.py index 0571dba7..2a39f48b 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -1,10 +1,42 @@ import logging +from datetime import date, datetime, timedelta, timezone +from typing import List, Optional +from uuid import UUID from fastapi import HTTPException -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload -from app.models import Match, MatchStatus, TimeBlock -from app.schemas.match import SubmitMatchRequest, SubmitMatchResponse +from app.models import Match, MatchStatus, TimeBlock, User +from app.models.UserData import UserData +from app.schemas.match import ( + MatchCreateRequest, + MatchCreateResponse, + MatchDetailForVolunteerResponse, + MatchDetailResponse, + MatchListForVolunteerResponse, + MatchListResponse, + MatchParticipantSummary, + MatchRequestNewVolunteersResponse, + MatchResponse, + MatchUpdateRequest, + MatchVolunteerSummary, +) +from app.schemas.time_block import TimeBlockEntity, TimeRange +from app.schemas.user import UserRole + +SCHEDULE_CLEANUP_STATUSES = { + "pending", + "requesting_new_times", + "requesting_new_volunteers", + "awaiting_volunteer_acceptance", +} +ACTIVE_MATCH_STATUSES = { + "pending", + "requesting_new_times", + "requesting_new_volunteers", + "confirmed", + "awaiting_volunteer_acceptance", +} class MatchService: @@ -12,40 +44,766 @@ def __init__(self, db: Session): self.db = db self.logger = logging.getLogger(__name__) - async def submit_time(self, req: SubmitMatchRequest) -> SubmitMatchResponse: + async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse: + try: + participant: User | None = self.db.get(User, req.participant_id) + if not participant: + raise HTTPException(404, f"Participant {req.participant_id} not found") + if participant.role is None or participant.role.name != UserRole.PARTICIPANT: + raise HTTPException(400, "Selected user is not a participant") + + # Default to awaiting_volunteer_acceptance (volunteers must accept before participants see matches) + status_name = req.match_status or "awaiting_volunteer_acceptance" + status = self.db.query(MatchStatus).filter_by(name=status_name).first() + if not status: + raise HTTPException(400, f"Invalid match status: {status_name}") + + created_matches: List[Match] = [] + created_visible_match = False + + for volunteer_id in req.volunteer_ids: + volunteer: User | None = ( + self.db.query(User).options(joinedload(User.availability)).filter(User.id == volunteer_id).first() + ) + if not volunteer: + raise HTTPException(404, f"Volunteer {volunteer_id} not found") + if volunteer.role is None or volunteer.role.name != UserRole.VOLUNTEER: + raise HTTPException(400, "Match volunteers must have volunteer role") + + match = Match( + participant_id=participant.id, + volunteer_id=volunteer.id, + match_status=status, + ) + self.db.add(match) + + if status_name != "awaiting_volunteer_acceptance": + self._attach_initial_suggested_times(match, volunteer) + created_visible_match = True + + created_matches.append(match) + + # Clear pending volunteer request flag if at least one match is visible to the participant + if created_visible_match: + participant.pending_volunteer_request = False + + self.db.flush() + self.db.commit() + + for match in created_matches: + self.db.refresh(match) + + responses = [self._build_match_response(match) for match in created_matches] + return MatchCreateResponse(matches=responses) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error creating matches for participant {req.participant_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to create matches") + + async def update_match(self, match_id: int, req: MatchUpdateRequest) -> MatchResponse: + try: + match: Match | None = self.db.get(Match, match_id) + if not match or match.deleted_at is not None: + raise HTTPException(404, f"Match {match_id} not found") + + volunteer_changed = False + if req.volunteer_id is not None and req.volunteer_id != match.volunteer_id: + volunteer: User | None = ( + self.db.query(User) + .options(joinedload(User.availability)) + .filter(User.id == req.volunteer_id) + .first() + ) + if not volunteer: + raise HTTPException(404, f"Volunteer {req.volunteer_id} not found") + if volunteer.role is None or volunteer.role.name != UserRole.VOLUNTEER: + raise HTTPException(400, "Match volunteers must have volunteer role") + self._reassign_volunteer(match, volunteer) + volunteer_changed = True + + if req.match_status is not None: + status = self.db.query(MatchStatus).filter_by(name=req.match_status).first() + if not status: + raise HTTPException(400, f"Invalid match status: {req.match_status}") + match.match_status = status + elif volunteer_changed: + awaiting_status = self.db.query(MatchStatus).filter_by(name="awaiting_volunteer_acceptance").first() + if not awaiting_status: + raise HTTPException(500, "Match status 'awaiting_volunteer_acceptance' not configured") + match.match_status = awaiting_status + + if req.chosen_time_block_id is not None: + block = self.db.get(TimeBlock, req.chosen_time_block_id) + if not block: + raise HTTPException(404, f"TimeBlock {req.chosen_time_block_id} not found") + match.chosen_time_block_id = block.id + match.confirmed_time = block + elif req.clear_chosen_time: + match.chosen_time_block_id = None + match.confirmed_time = None + + final_status_name = match.match_status.name if match.match_status else None + if final_status_name != "awaiting_volunteer_acceptance" and not match.suggested_time_blocks: + volunteer_with_availability: User | None = ( + self.db.query(User) + .options(joinedload(User.availability)) + .filter(User.id == match.volunteer_id) + .first() + ) + if volunteer_with_availability: + self._attach_initial_suggested_times(match, volunteer_with_availability) + + self.db.flush() + self.db.commit() + self.db.refresh(match) + + return self._build_match_response(match) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error updating match {match_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to update match") + + async def schedule_match( + self, + match_id: int, + time_block_id: int, + acting_participant_id: Optional[UUID] = None, + ) -> MatchDetailResponse: try: - match = self.db.get(Match, req.match_id) + match: Match | None = ( + self.db.query(Match) + .options( + joinedload(Match.participant), + joinedload(Match.suggested_time_blocks), + joinedload(Match.match_status), + ) + .filter(Match.id == match_id, Match.deleted_at.is_(None)) + .first() + ) if not match: - raise HTTPException(404, f"Match {req.match_id} not found") + raise HTTPException(404, f"Match {match_id} not found") - block = self.db.get(TimeBlock, req.time_block_id) + if acting_participant_id and match.participant_id != acting_participant_id: + raise HTTPException(status_code=403, detail="Cannot modify another participant's match") + + block = self.db.get(TimeBlock, time_block_id) if not block: - raise HTTPException(404, f"TimeBlock {req.time_block_id} not found") + raise HTTPException(404, f"TimeBlock {time_block_id} not found") + + # Validate that the time block belongs to this match's suggested times + if block not in match.suggested_time_blocks: + raise HTTPException( + 400, "Selected time is not available for this match. Please choose from suggested times." + ) + + # Check if volunteer is already confirmed at this exact time (prevent double-booking) + conflicting_match = ( + self.db.query(Match) + .join(TimeBlock, Match.chosen_time_block_id == TimeBlock.id) + .filter( + Match.volunteer_id == match.volunteer_id, + TimeBlock.start_time == block.start_time, + Match.id != match.id, + Match.deleted_at.is_(None), + Match.match_status.has(MatchStatus.name == "confirmed"), + ) + .first() + ) - if block.confirmed_match and block.confirmed_match.id != match.id: - raise HTTPException(400, "TimeBlock already confirmed for another match") + if conflicting_match: + raise HTTPException( + 409, + "This volunteer has already confirmed another appointment at this time. " + "Please choose a different time slot.", + ) - # confirm timeblock in match match.chosen_time_block_id = block.id match.confirmed_time = block - match.match_status = self.db.get(MatchStatus, 6) + + confirmed_status = self.db.query(MatchStatus).filter_by(name="confirmed").first() + if not confirmed_status: + raise HTTPException(500, "Match status 'confirmed' not configured") + match.match_status = confirmed_status + + participant_id = match.participant_id + + other_matches: List[Match] = ( + self.db.query(Match) + .options( + joinedload(Match.match_status), + joinedload(Match.suggested_time_blocks), + joinedload(Match.confirmed_time), + ) + .filter( + Match.participant_id == participant_id, + Match.id != match.id, + Match.deleted_at.is_(None), + ) + .all() + ) + + for other in other_matches: + status_name = other.match_status.name if other.match_status else None + if status_name in SCHEDULE_CLEANUP_STATUSES: + self._delete_match(other) + + self.db.flush() + self.db.commit() + self.db.refresh(match) + + return self._build_match_detail(match) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error scheduling match {match_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to schedule match") + + async def request_new_times( + self, + match_id: int, + time_ranges: List[TimeRange], + acting_participant_id: Optional[UUID] = None, + ) -> MatchDetailResponse: + try: + match: Match | None = ( + self.db.query(Match) + .options(joinedload(Match.suggested_time_blocks), joinedload(Match.match_status)) + .filter(Match.id == match_id, Match.deleted_at.is_(None)) + .first() + ) + if not match: + raise HTTPException(404, f"Match {match_id} not found") + + if acting_participant_id and match.participant_id != acting_participant_id: + raise HTTPException(status_code=403, detail="Cannot modify another participant's match") + + if match.chosen_time_block_id is not None: + raise HTTPException(400, "Cannot request new times after a call is scheduled") + + for existing in list(match.suggested_time_blocks): + match.suggested_time_blocks.remove(existing) + self.db.delete(existing) + + added = 0 + for time_range in time_ranges: + current_start = time_range.start_time + end_time = time_range.end_time + while current_start + timedelta(minutes=30) <= end_time: + time_block = TimeBlock(start_time=current_start) + match.suggested_time_blocks.append(time_block) + added += 1 + current_start += timedelta(minutes=30) + + if added == 0: + raise HTTPException(400, "No suggested time blocks generated from provided ranges") + + requesting_status = self.db.query(MatchStatus).filter_by(name="requesting_new_times").first() + if not requesting_status: + raise HTTPException(500, "Match status 'requesting_new_times' not configured") + + match.match_status = requesting_status + + self.db.flush() + self.db.commit() + self.db.refresh(match) + + return self._build_match_detail(match) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error requesting new times for match {match_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to request new times") + + async def cancel_match_by_participant( + self, + match_id: int, + acting_participant_id: Optional[UUID] = None, + ) -> MatchDetailResponse: + try: + match: Match | None = ( + self.db.query(Match) + .options(joinedload(Match.volunteer), joinedload(Match.match_status)) + .filter(Match.id == match_id, Match.deleted_at.is_(None)) + .first() + ) + if not match: + raise HTTPException(404, f"Match {match_id} not found") + + if acting_participant_id and match.participant_id != acting_participant_id: + raise HTTPException(status_code=403, detail="Cannot modify another participant's match") + + self._clear_confirmed_time(match) + self._set_match_status(match, "cancelled_by_participant") + + self.db.flush() + self.db.commit() + self.db.refresh(match) + + return self._build_match_detail(match) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error cancelling match {match_id} as participant: {exc}") + raise HTTPException(status_code=500, detail="Failed to cancel match") + + async def cancel_match_by_volunteer( + self, + match_id: int, + acting_volunteer_id: Optional[UUID] = None, + ) -> MatchDetailResponse: + try: + match: Match | None = ( + self.db.query(Match) + .options(joinedload(Match.volunteer), joinedload(Match.match_status)) + .filter(Match.id == match_id, Match.deleted_at.is_(None)) + .first() + ) + if not match: + raise HTTPException(404, f"Match {match_id} not found") + + if acting_volunteer_id and match.volunteer_id != acting_volunteer_id: + raise HTTPException(status_code=403, detail="Cannot modify another volunteer's match") + + self._clear_confirmed_time(match) + self._set_match_status(match, "cancelled_by_volunteer") self.db.flush() + self.db.commit() + self.db.refresh(match) + + return self._build_match_detail(match) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error cancelling match {match_id} as volunteer: {exc}") + raise HTTPException(status_code=500, detail="Failed to cancel match") + + async def get_matches_for_participant(self, participant_id: UUID) -> MatchListResponse: + try: + # Get participant to check pending request flag + participant: User | None = self.db.get(User, participant_id) + if not participant: + raise HTTPException(404, f"Participant {participant_id} not found") + + # Get matches excluding those awaiting volunteer acceptance (participants shouldn't see these yet) + matches: List[Match] = ( + self.db.query(Match) + .options( + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.treatments), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(Match.match_status), + joinedload(Match.suggested_time_blocks), + joinedload(Match.confirmed_time), + ) + .filter( + Match.participant_id == participant_id, + Match.deleted_at.is_(None), + ~Match.match_status.has(MatchStatus.name == "awaiting_volunteer_acceptance"), + ) + .order_by(Match.created_at.desc()) + .all() + ) + + responses = [self._build_match_detail(match) for match in matches] + return MatchListResponse( + matches=responses, + has_pending_request=participant.pending_volunteer_request or False, + ) + except HTTPException: + raise + except Exception as exc: + self.logger.error(f"Error fetching matches for participant {participant_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to fetch matches") + + async def get_matches_for_volunteer(self, volunteer_id: UUID) -> MatchListForVolunteerResponse: + """Get all matches for a volunteer, including those awaiting acceptance.""" + try: + volunteer: User | None = self.db.get(User, volunteer_id) + if not volunteer: + raise HTTPException(404, f"Volunteer {volunteer_id} not found") + if volunteer.role is None or volunteer.role.name != UserRole.VOLUNTEER: + raise HTTPException(400, "Selected user is not a volunteer") + + matches: List[Match] = ( + self.db.query(Match) + .options( + joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.treatments), + joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(Match.match_status), + ) + .filter(Match.volunteer_id == volunteer_id, Match.deleted_at.is_(None)) + .order_by(Match.created_at.desc()) + .all() + ) + + responses = [self._build_match_detail_for_volunteer(match) for match in matches] + return MatchListForVolunteerResponse(matches=responses) + except HTTPException: + raise + except Exception as exc: + self.logger.error(f"Error fetching matches for volunteer {volunteer_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to fetch matches") - response = SubmitMatchResponse.model_validate( - { - "match_id": match.id, - "time_block": block, - } + async def volunteer_accept_match( + self, + match_id: int, + acting_volunteer_id: Optional[UUID] = None, + ) -> MatchDetailResponse: + """Volunteer accepts a match and sends their general availability to participant.""" + try: + match: Match | None = ( + self.db.query(Match) + .options( + joinedload(Match.volunteer).joinedload(User.availability), + joinedload(Match.match_status), + ) + .filter(Match.id == match_id, Match.deleted_at.is_(None)) + .first() ) + if not match: + raise HTTPException(404, f"Match {match_id} not found") + + # Verify volunteer ownership + if acting_volunteer_id and match.volunteer_id != acting_volunteer_id: + raise HTTPException(status_code=403, detail="Cannot modify another volunteer's match") + + # Check current status + if not match.match_status or match.match_status.name != "awaiting_volunteer_acceptance": + raise HTTPException( + 400, + f"Match is not awaiting volunteer acceptance. Current status: {match.match_status.name if match.match_status else 'unknown'}", + ) + + volunteer = match.volunteer + if not volunteer: + raise HTTPException(404, f"Volunteer {match.volunteer_id} not found") + + # Validate that volunteer has availability before accepting + if not self._has_valid_availability(volunteer): + raise HTTPException( + 400, + "Cannot accept match: volunteer must have availability set before accepting matches. " + "Please add your availability times first.", + ) + # Clear any existing suggested time blocks (shouldn't exist, but be safe) + for block in list(match.suggested_time_blocks): + match.suggested_time_blocks.remove(block) + self.db.delete(block) + + # Attach volunteer's general availability as suggested times + self._attach_initial_suggested_times(match, volunteer) + + # Transition status to "pending" so participant can see it + self._set_match_status(match, "pending") + + self.db.flush() self.db.commit() - return response + self.db.refresh(match) + # Return match detail for participant view (includes suggested times) + return self._build_match_detail(match) except HTTPException: self.db.rollback() raise except Exception as exc: self.db.rollback() - self.logger.error(f"Error confirming time for match {req.match_id}: {exc}") - raise HTTPException(status_code=500, detail="Failed to confirm time") + self.logger.error(f"Error accepting match {match_id} for volunteer: {exc}") + raise HTTPException(status_code=500, detail="Failed to accept match") + + def _build_match_detail_for_volunteer(self, match: Match) -> MatchDetailForVolunteerResponse: + """Build match detail response for volunteer view (includes participant info).""" + participant = match.participant + if not participant: + raise HTTPException(500, "Match is missing participant data") + + participant_data = participant.user_data + + pronouns = None + diagnosis = None + age: Optional[int] = None + treatments: List[str] = [] + experiences: List[str] = [] + timezone: Optional[str] = None + + if participant_data: + if participant_data.pronouns: + pronouns = participant_data.pronouns + diagnosis = participant_data.diagnosis + + if participant_data.date_of_birth: + age = self._calculate_age(participant_data.date_of_birth) + + if participant_data.treatments: + treatments = [t.name for t in participant_data.treatments if t and t.name] + + if participant_data.experiences: + experiences = [e.name for e in participant_data.experiences if e and e.name] + + timezone = participant_data.timezone + + participant_summary = MatchParticipantSummary( + id=participant.id, + first_name=participant.first_name, + last_name=participant.last_name, + email=participant.email, + pronouns=pronouns, + diagnosis=diagnosis, + age=age, + treatments=treatments, + experiences=experiences, + timezone=timezone, + ) + + match_status_name = match.match_status.name if match.match_status else "" + + return MatchDetailForVolunteerResponse( + id=match.id, + participant_id=match.participant_id, + volunteer_id=match.volunteer_id, + participant=participant_summary, + match_status=match_status_name, + created_at=match.created_at, + updated_at=match.updated_at, + ) + + async def request_new_volunteers( + self, + participant_id: UUID, + acting_participant_id: Optional[UUID] = None, + ) -> MatchRequestNewVolunteersResponse: + try: + if acting_participant_id and participant_id != acting_participant_id: + raise HTTPException(status_code=403, detail="Cannot modify another participant's matches") + + # Get participant and set pending flag + participant: User | None = self.db.get(User, participant_id) + if not participant: + raise HTTPException(404, f"Participant {participant_id} not found") + + matches: List[Match] = ( + self.db.query(Match) + .options( + joinedload(Match.suggested_time_blocks), + joinedload(Match.confirmed_time), + joinedload(Match.match_status), + ) + .filter(Match.participant_id == participant_id, Match.deleted_at.is_(None)) + .all() + ) + + deleted_count = 0 + for match in matches: + status_name = match.match_status.name if match.match_status else None + if status_name in ACTIVE_MATCH_STATUSES: + self._delete_match(match) + deleted_count += 1 + + # Set pending volunteer request flag + participant.pending_volunteer_request = True + + self.db.flush() + self.db.commit() + + return MatchRequestNewVolunteersResponse(deleted_matches=deleted_count) + + except HTTPException: + self.db.rollback() + raise + except Exception as exc: + self.db.rollback() + self.logger.error(f"Error requesting new volunteers for participant {participant_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to request new volunteers") + + def _build_match_response(self, match: Match) -> MatchResponse: + match_status_name = match.match_status.name if match.match_status else "" + return MatchResponse( + id=match.id, + participant_id=match.participant_id, + volunteer_id=match.volunteer_id, + match_status=match_status_name, + chosen_time_block_id=match.chosen_time_block_id, + created_at=match.created_at, + updated_at=match.updated_at, + ) + + def _build_match_detail(self, match: Match) -> MatchDetailResponse: + volunteer = match.volunteer + if not volunteer: + raise HTTPException(500, "Match is missing volunteer data") + + volunteer_data = volunteer.user_data + + pronouns = None + diagnosis = None + age: Optional[int] = None + treatments: List[str] = [] + experiences: List[str] = [] + + if volunteer_data: + if volunteer_data.pronouns: + pronouns = volunteer_data.pronouns + diagnosis = volunteer_data.diagnosis + + if volunteer_data.date_of_birth: + age = self._calculate_age(volunteer_data.date_of_birth) + + if volunteer_data.treatments: + treatments = [t.name for t in volunteer_data.treatments if t and t.name] + + if volunteer_data.experiences: + experiences = [e.name for e in volunteer_data.experiences if e and e.name] + + volunteer_summary = MatchVolunteerSummary( + id=volunteer.id, + first_name=volunteer.first_name, + last_name=volunteer.last_name, + email=volunteer.email, + pronouns=pronouns, + diagnosis=diagnosis, + age=age, + treatments=treatments, + experiences=experiences, + ) + + suggested_blocks = [ + TimeBlockEntity.model_validate(time_block) + for time_block in sorted( + match.suggested_time_blocks, + key=lambda tb: tb.start_time, + ) + ] + + chosen_block = None + if match.confirmed_time: + chosen_block = TimeBlockEntity.model_validate(match.confirmed_time) + + match_status_name = match.match_status.name if match.match_status else "" + + return MatchDetailResponse( + id=match.id, + participant_id=match.participant_id, + volunteer=volunteer_summary, + match_status=match_status_name, + chosen_time_block=chosen_block, + suggested_time_blocks=suggested_blocks, + created_at=match.created_at, + updated_at=match.updated_at, + ) + + def _delete_match(self, match: Match) -> None: + confirmed_block = match.confirmed_time + for block in list(match.suggested_time_blocks): + match.suggested_time_blocks.remove(block) + self.db.delete(block) + + match.confirmed_time = None + match.chosen_time_block_id = None + match.deleted_at = datetime.now(timezone.utc) + + if confirmed_block and confirmed_block not in self.db.deleted: + self.db.delete(confirmed_block) + + def _set_match_status(self, match: Match, status_name: str) -> None: + status = self.db.query(MatchStatus).filter_by(name=status_name).first() + if not status: + raise HTTPException(500, f"Match status '{status_name}' not configured") + match.match_status = status + + def _clear_confirmed_time(self, match: Match) -> None: + if not match.confirmed_time: + return + + confirmed_block = match.confirmed_time + match.confirmed_time = None + match.chosen_time_block_id = None + + if confirmed_block in match.suggested_time_blocks: + match.suggested_time_blocks.remove(confirmed_block) + + if confirmed_block not in self.db.deleted: + self.db.delete(confirmed_block) + + @staticmethod + def _calculate_age(birth_date: date) -> Optional[int]: + today = date.today() + if birth_date is None: + return None + if birth_date > today: + return None + + years = today.year - birth_date.year + has_had_birthday = (today.month, today.day) >= (birth_date.month, birth_date.day) + return years if has_had_birthday else years - 1 + + def _has_valid_availability(self, volunteer: User) -> bool: + """Check if volunteer has any valid future availability blocks.""" + if not volunteer.availability: + return False + + now = datetime.now(timezone.utc) + for block in volunteer.availability: + if block.start_time is None: + continue + if block.start_time < now: + continue + if block.start_time.minute not in {0, 30}: + continue + # Found at least one valid future availability block + return True + + return False + + def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None: + if not volunteer.availability: + return + + now = datetime.now(timezone.utc) + sorted_blocks = sorted( + volunteer.availability, + key=lambda tb: tb.start_time or now, + ) + + for block in sorted_blocks: + if block.start_time is None: + continue + if block.start_time < now: + continue + if block.start_time.minute not in {0, 30}: + continue + + new_block = TimeBlock(start_time=block.start_time) + match.suggested_time_blocks.append(new_block) + + def _reassign_volunteer(self, match: Match, volunteer: User) -> None: + match.volunteer_id = volunteer.id + + # Clear confirmed selection + self._clear_confirmed_time(match) + + # Remove existing suggested blocks + for block in list(match.suggested_time_blocks): + match.suggested_time_blocks.remove(block) + self.db.delete(block) + + # Suggested times are attached once the volunteer accepts the match diff --git a/backend/app/services/implementations/suggested_times_service.py b/backend/app/services/implementations/suggested_times_service.py index ba57187f..f63e0831 100644 --- a/backend/app/services/implementations/suggested_times_service.py +++ b/backend/app/services/implementations/suggested_times_service.py @@ -46,22 +46,15 @@ async def create_suggested_time(self, req: SuggestedTimeCreateRequest) -> Sugges match = self.db.query(Match).filter_by(id=match_id).one() for time_range in suggested_new_times: - # time format looks like: 2025-03-17 09:30:00 start_time = time_range.start_time end_time = time_range.end_time - # create timeblocks (0.5 hr) with 15 min spacing current_start_time = start_time - while current_start_time + timedelta(hours=0.5) <= end_time: - # create time block + while current_start_time + timedelta(minutes=30) <= end_time: time_block = TimeBlock(start_time=current_start_time) - - # add to suggestedTime match.suggested_time_blocks.append(time_block) - - # update current time by 15 minutes for the next block - current_start_time += timedelta(minutes=15) added += 1 + current_start_time += timedelta(minutes=30) self.db.flush() # push inserts, get DB-generated fields populated validated_data = SuggestedTimeCreateResponse.model_validate({"match_id": match_id, "added": added}) diff --git a/backend/app/services/implementations/task_service.py b/backend/app/services/implementations/task_service.py index 82fb20f6..93daa1ed 100644 --- a/backend/app/services/implementations/task_service.py +++ b/backend/app/services/implementations/task_service.py @@ -41,6 +41,7 @@ async def create_task(self, task: TaskCreateRequest) -> TaskResponse: assignee_id=task.assignee_id, start_date=task.start_date or datetime.utcnow(), end_date=task.end_date, + description=task.description, ) self.db.add(db_task) 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/backend/migrations/versions/14fdeccc883f_add_pending_volunteer_request_field_to_.py b/backend/migrations/versions/14fdeccc883f_add_pending_volunteer_request_field_to_.py new file mode 100644 index 00000000..90e69b3e --- /dev/null +++ b/backend/migrations/versions/14fdeccc883f_add_pending_volunteer_request_field_to_.py @@ -0,0 +1,30 @@ +"""Add pending_volunteer_request field to User model + +Revision ID: 14fdeccc883f +Revises: d87b1000d48b +Create Date: 2025-10-19 23:29:15.111257 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "14fdeccc883f" +down_revision: Union[str, None] = "d87b1000d48b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("pending_volunteer_request", sa.Boolean(), nullable=False, server_default="false")) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "pending_volunteer_request") + # ### end Alembic commands ### diff --git a/backend/migrations/versions/d87b1000d48b_add_soft_deletion_to_matches_table.py b/backend/migrations/versions/d87b1000d48b_add_soft_deletion_to_matches_table.py new file mode 100644 index 00000000..6b45ea89 --- /dev/null +++ b/backend/migrations/versions/d87b1000d48b_add_soft_deletion_to_matches_table.py @@ -0,0 +1,30 @@ +"""add soft deletion to matches table + +Revision ID: d87b1000d48b +Revises: e3f0a5b4b7c4 +Create Date: 2025-10-15 00:01:11.343457 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d87b1000d48b" +down_revision: Union[str, None] = "e3f0a5b4b7c4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("matches", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + op.drop_column("matches", "deleted_at") + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e3f0a5b4b7c4_add_task_description_column.py b/backend/migrations/versions/e3f0a5b4b7c4_add_task_description_column.py new file mode 100644 index 00000000..3b301285 --- /dev/null +++ b/backend/migrations/versions/e3f0a5b4b7c4_add_task_description_column.py @@ -0,0 +1,26 @@ +"""add task description column + +Revision ID: e3f0a5b4b7c4 +Revises: 9f1a6d727929 +Create Date: 2025-02-14 05:30:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e3f0a5b4b7c4" +down_revision: Union[str, None] = "9f1a6d727929" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("description", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tasks", "description") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f5da457a..110f4be8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,6 +42,10 @@ upgrade = "alembic upgrade head" seed = "python -m app.seeds.runner" db-reset = {composite = ["docker-db", "upgrade", "seed"]} tests = "pytest -v" +test-db-create = {shell = "docker exec llsc_db psql -U postgres -c 'DROP DATABASE IF EXISTS llsc_test' 2>/dev/null || true && docker exec llsc_db psql -U postgres -c 'CREATE DATABASE llsc_test'"} +test-db-migrate = {shell = "POSTGRES_DATABASE_URL='postgresql+psycopg2://postgres:postgres@localhost:5432/llsc_test' alembic upgrade head"} +test-db-seed = {shell = "POSTGRES_DATABASE_URL='postgresql+psycopg2://postgres:postgres@localhost:5432/llsc_test' python -m app.seeds.runner"} +test-setup = {composite = ["test-db-create", "test-db-migrate", "test-db-seed"]} [tool.pytest.ini_options] asyncio_mode = "strict" diff --git a/backend/test_intake.db b/backend/test_intake.db deleted file mode 100644 index d603d9b1..00000000 Binary files a/backend/test_intake.db and /dev/null differ diff --git a/backend/tests/unit/test_match_route_helpers.py b/backend/tests/unit/test_match_route_helpers.py new file mode 100644 index 00000000..10fc0f0d --- /dev/null +++ b/backend/tests/unit/test_match_route_helpers.py @@ -0,0 +1,136 @@ +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.routes.match import _resolve_acting_participant_id, _resolve_acting_volunteer_id +from app.schemas.user import UserRole + + +class DummyUserService: + def __init__(self, role_map, id_map): + self.role_map = role_map + self.id_map = id_map + + def get_user_role_by_auth_id(self, auth_id: str) -> str: + if auth_id not in self.role_map: + raise ValueError("User not found") + return self.role_map[auth_id] + + async def get_user_id_by_auth_id(self, auth_id: str) -> str: + if auth_id not in self.id_map: + raise ValueError("ID not found") + return self.id_map[auth_id] + + +@pytest.mark.asyncio +async def test_resolve_participant_success(): + request = Request({"type": "http"}) + request.state.user_id = "auth_participant" + + service = DummyUserService( + role_map={"auth_participant": UserRole.PARTICIPANT.value}, + id_map={"auth_participant": "11111111-1111-1111-1111-111111111111"}, + ) + + participant_id = await _resolve_acting_participant_id(request, service) + assert str(participant_id) == "11111111-1111-1111-1111-111111111111" + + +@pytest.mark.asyncio +async def test_resolve_participant_admin_bypass(): + request = Request({"type": "http"}) + request.state.user_id = "auth_admin" + + service = DummyUserService( + role_map={"auth_admin": UserRole.ADMIN.value}, + id_map={}, + ) + + participant_id = await _resolve_acting_participant_id(request, service) + assert participant_id is None + + +@pytest.mark.asyncio +async def test_resolve_participant_missing_user_raises(): + request = Request({"type": "http"}) + request.state.user_id = "auth_unknown" + + service = DummyUserService(role_map={}, id_map={}) + + with pytest.raises(HTTPException) as exc: + await _resolve_acting_participant_id(request, service) + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_resolve_participant_wrong_role_raises(): + request = Request({"type": "http"}) + request.state.user_id = "auth_volunteer" + + service = DummyUserService( + role_map={"auth_volunteer": UserRole.VOLUNTEER.value}, + id_map={}, + ) + + with pytest.raises(HTTPException) as exc: + await _resolve_acting_participant_id(request, service) + + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_resolve_volunteer_success(): + request = Request({"type": "http"}) + request.state.user_id = "auth_volunteer" + + service = DummyUserService( + role_map={"auth_volunteer": UserRole.VOLUNTEER.value}, + id_map={"auth_volunteer": "22222222-2222-2222-2222-222222222222"}, + ) + + volunteer_id = await _resolve_acting_volunteer_id(request, service) + assert str(volunteer_id) == "22222222-2222-2222-2222-222222222222" + + +@pytest.mark.asyncio +async def test_resolve_volunteer_missing_user_raises(): + request = Request({"type": "http"}) + request.state.user_id = "auth_missing" + + service = DummyUserService(role_map={}, id_map={}) + + with pytest.raises(HTTPException) as exc: + await _resolve_acting_volunteer_id(request, service) + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_resolve_volunteer_wrong_role_raises(): + request = Request({"type": "http"}) + request.state.user_id = "auth_participant" + + service = DummyUserService( + role_map={"auth_participant": UserRole.PARTICIPANT.value}, + id_map={}, + ) + + with pytest.raises(HTTPException) as exc: + await _resolve_acting_volunteer_id(request, service) + + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_resolve_volunteer_admin_bypass(): + request = Request({"type": "http"}) + request.state.user_id = "auth_admin" + + service = DummyUserService( + role_map={"auth_admin": UserRole.ADMIN.value}, + id_map={}, + ) + + volunteer_id = await _resolve_acting_volunteer_id(request, service) + assert volunteer_id is None diff --git a/backend/tests/unit/test_match_service.py b/backend/tests/unit/test_match_service.py new file mode 100644 index 00000000..aa1901bd --- /dev/null +++ b/backend/tests/unit/test_match_service.py @@ -0,0 +1,1655 @@ +"""Integration tests for MatchService. + +Tests all match service methods with a real database. + +NOTE: These tests are designed to work with BOTH Postgres and SQLite. +- If POSTGRES_TEST_DATABASE_URL is set: Uses Postgres (like test_user.py) +- Otherwise: Skips tests with a message to run unit tests for TimeRange validation only + +For full test coverage, set up Postgres test DB or run validation tests only. +""" + +import os +from datetime import datetime, timedelta, timezone +from uuid import UUID + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +from app.models import Match, MatchStatus, Role, TimeBlock, User +from app.schemas.match import ( + MatchCreateRequest, + MatchRequestNewVolunteersResponse, + MatchUpdateRequest, +) +from app.schemas.time_block import TimeRange +from app.schemas.user import UserRole +from app.services.implementations.match_service import MatchService + +# Check for Postgres test database (same pattern as test_user.py) +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") + +if not POSTGRES_DATABASE_URL: + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "Run TimeRange validation tests only: pytest tests/unit/test_time_block_validation.py", + allow_module_level=True, + ) + +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test (Postgres only). + + Assumes Alembic migrations have run. Seeds roles and match statuses. + """ + session = TestingSessionLocal() + + try: + # Clean up match-related data (be careful with FK constraints) + session.execute( + text( + "TRUNCATE TABLE suggested_times, available_times, matches, time_blocks, tasks RESTART IDENTITY CASCADE" + ) + ) + session.execute(text("TRUNCATE TABLE users RESTART IDENTITY CASCADE")) + session.commit() + + # Seed roles if missing + existing_roles = {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 seed_roles: + if role.id not in existing_roles: + try: + session.add(role) + session.commit() + except IntegrityError: + session.rollback() + + # Seed match statuses if missing + existing_statuses = {s.id for s in session.query(MatchStatus).all()} + seed_statuses = [ + MatchStatus(id=1, name="pending"), + MatchStatus(id=2, name="confirmed"), + MatchStatus(id=3, name="cancelled_by_participant"), + MatchStatus(id=4, name="completed"), + MatchStatus(id=5, name="no_show"), + MatchStatus(id=6, name="rescheduled"), + MatchStatus(id=7, name="cancelled_by_volunteer"), + MatchStatus(id=8, name="requesting_new_times"), + MatchStatus(id=9, name="requesting_new_volunteers"), + MatchStatus(id=10, name="awaiting_volunteer_acceptance"), + ] + for status in seed_statuses: + if status.id not in existing_statuses: + try: + session.add(status) + session.commit() + except IntegrityError: + session.rollback() + + yield session + finally: + session.rollback() + session.close() + + +@pytest.fixture +def participant_user(db_session): + """Create a test participant.""" + user = User( + first_name="Test", + last_name="Participant", + email="participant@example.com", + role_id=1, + auth_id="participant_auth_id", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def another_participant(db_session): + """Create another participant for ownership tests.""" + user = User( + first_name="Another", + last_name="Participant", + email="participant2@example.com", + role_id=1, + auth_id="participant2_auth_id", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def volunteer_user(db_session): + """Create a test volunteer without availability.""" + user = User( + first_name="Test", + last_name="Volunteer", + email="volunteer@example.com", + role_id=2, + auth_id="volunteer_auth_id", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def another_volunteer(db_session): + """Create another volunteer for testing.""" + user = User( + first_name="Another", + last_name="Volunteer", + email="volunteer2@example.com", + role_id=2, + auth_id="volunteer2_auth_id", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def volunteer_with_availability(db_session, volunteer_user): + """Create volunteer with future availability on half-hour boundaries.""" + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + # Create availability: tomorrow at 10:00, 10:30, 11:00, 11:30 + times = [ + tomorrow.replace(hour=10, minute=0, second=0, microsecond=0), + tomorrow.replace(hour=10, minute=30, second=0, microsecond=0), + tomorrow.replace(hour=11, minute=0, second=0, microsecond=0), + tomorrow.replace(hour=11, minute=30, second=0, microsecond=0), + ] + + for time in times: + block = TimeBlock(start_time=time) + volunteer_user.availability.append(block) + + db_session.commit() + db_session.refresh(volunteer_user) + return volunteer_user + + +@pytest.fixture +def volunteer_with_mixed_availability(db_session, another_volunteer): + """Create volunteer with past times and non-half-hour times (should be filtered).""" + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + tomorrow = now + timedelta(days=1) + + times = [ + # Past time (should be filtered) + yesterday.replace(hour=10, minute=0, second=0, microsecond=0), + # Non-half-hour (should be filtered) + tomorrow.replace(hour=10, minute=15, second=0, microsecond=0), + # Valid future times + tomorrow.replace(hour=14, minute=0, second=0, microsecond=0), + tomorrow.replace(hour=14, minute=30, second=0, microsecond=0), + ] + + for time in times: + block = TimeBlock(start_time=time) + another_volunteer.availability.append(block) + + db_session.commit() + db_session.refresh(another_volunteer) + return another_volunteer + + +@pytest.fixture +def volunteer_with_alt_availability(db_session): + """Create a different volunteer with distinct availability.""" + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=2) + + volunteer = User( + first_name="Alt", + last_name="Volunteer", + email="volunteer_alt@example.com", + role_id=2, + auth_id="volunteer_alt_auth_id", + ) + db_session.add(volunteer) + db_session.flush() + + slots = [ + tomorrow.replace(hour=9, minute=0, second=0, microsecond=0), + tomorrow.replace(hour=9, minute=30, second=0, microsecond=0), + ] + for slot in slots: + volunteer.availability.append(TimeBlock(start_time=slot)) + + db_session.commit() + db_session.refresh(volunteer) + return volunteer + + +@pytest.fixture +def sample_match(db_session, participant_user, volunteer_user): + """Create a pending match with suggested times.""" + match = Match( + participant_id=participant_user.id, + volunteer_id=volunteer_user.id, + match_status_id=1, # pending + ) + db_session.add(match) + db_session.flush() + + # Add suggested times + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + times = [ + tomorrow.replace(hour=14, minute=0, second=0, microsecond=0), + tomorrow.replace(hour=14, minute=30, second=0, microsecond=0), + tomorrow.replace(hour=15, minute=0, second=0, microsecond=0), + ] + + for time in times: + block = TimeBlock(start_time=time) + match.suggested_time_blocks.append(block) + + db_session.commit() + db_session.refresh(match) + return match + + +# ========== CREATE MATCHES TESTS ========== + + +class TestCreateMatches: + """Test match creation functionality.""" + + @pytest.mark.asyncio + async def test_create_match_success(self, db_session, participant_user, volunteer_user): + """Admin can create a match successfully""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + ) + + response = await match_service.create_matches(request) + + assert len(response.matches) == 1 + assert response.matches[0].participant_id == participant_user.id + assert response.matches[0].volunteer_id == volunteer_user.id + assert response.matches[0].match_status == "awaiting_volunteer_acceptance" + + match = db_session.get(Match, response.matches[0].id) + assert match is not None + assert match.match_status.name == "awaiting_volunteer_acceptance" + assert len(match.suggested_time_blocks) == 0 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_match_copies_volunteer_availability( + self, db_session, participant_user, volunteer_with_availability + ): + """Match should copy volunteer's availability as suggested times""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_with_availability.id], + ) + + response = await match_service.create_matches(request) + match_id = response.matches[0].id + + match = db_session.get(Match, match_id) + assert match is not None + assert match.match_status.name == "awaiting_volunteer_acceptance" + assert len(match.suggested_time_blocks) == 0 + + detail = await match_service.volunteer_accept_match(match_id, volunteer_with_availability.id) + assert detail.match_status == "pending" + assert len(detail.suggested_time_blocks) == 4 + + db_session.refresh(match) + assert match.match_status.name == "pending" + assert len(match.suggested_time_blocks) == 4 + + for block in match.suggested_time_blocks: + assert block.start_time.minute in {0, 30} + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_match_filters_past_and_invalid_times( + self, db_session, participant_user, volunteer_with_mixed_availability + ): + """Should filter out past times and non-half-hour times""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_with_mixed_availability.id], + ) + + response = await match_service.create_matches(request) + match_id = response.matches[0].id + + match = db_session.get(Match, match_id) + assert match is not None + assert len(match.suggested_time_blocks) == 0 + + detail = await match_service.volunteer_accept_match(match_id, volunteer_with_mixed_availability.id) + assert len(detail.suggested_time_blocks) == 2 + + # Should only have 2 valid future times (14:00, 14:30) + # Past time and :15 time should be filtered out + db_session.refresh(match) + assert len(match.suggested_time_blocks) == 2 + + # Verify all are at :00 or :30 + for block in match.suggested_time_blocks: + assert block.start_time.minute in {0, 30} + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_multiple_matches_for_participant( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """Can create multiple matches for same participant""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id, another_volunteer.id], + ) + + response = await match_service.create_matches(request) + + assert len(response.matches) == 2 + assert all(m.participant_id == participant_user.id for m in response.matches) + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_match_with_custom_status(self, db_session, participant_user, volunteer_user): + """Can create match with custom status""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + match_status="confirmed", + ) + + response = await match_service.create_matches(request) + + assert response.matches[0].match_status == "confirmed" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_match_with_pending_status_copies_availability( + self, db_session, participant_user, volunteer_with_availability + ): + """Explicit pending status copies volunteer availability immediately.""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_with_availability.id], + match_status="pending", + ) + + response = await match_service.create_matches(request) + match_id = response.matches[0].id + + match = db_session.get(Match, match_id) + assert match is not None + assert match.match_status.name == "pending" + assert len(match.suggested_time_blocks) == 4 + + for block in match.suggested_time_blocks: + assert block.start_time.minute in {0, 30} + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_create_match_invalid_participant_404(self, db_session, volunteer_user): + """404 when participant doesn't exist""" + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=UUID("00000000-0000-0000-0000-000000000000"), + volunteer_ids=[volunteer_user.id], + ) + + with pytest.raises(HTTPException) as exc_info: + await match_service.create_matches(request) + + assert exc_info.value.status_code == 404 + assert "Participant" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_create_match_invalid_volunteer_404(self, db_session, participant_user): + """404 when volunteer doesn't exist""" + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[UUID("00000000-0000-0000-0000-000000000000")], + ) + + with pytest.raises(HTTPException) as exc_info: + await match_service.create_matches(request) + + assert exc_info.value.status_code == 404 + assert "Volunteer" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_create_match_participant_has_wrong_role_400(self, db_session, volunteer_user): + """400 when trying to use volunteer as participant""" + match_service = MatchService(db_session) + # Try to use volunteer as participant + request = MatchCreateRequest( + participant_id=volunteer_user.id, + volunteer_ids=[volunteer_user.id], + ) + + with pytest.raises(HTTPException) as exc_info: + await match_service.create_matches(request) + + assert exc_info.value.status_code == 400 + assert "not a participant" in exc_info.value.detail.lower() + + +# ========== GET MATCHES TESTS ========== + + +class TestGetMatches: + """Test retrieving matches.""" + + @pytest.mark.asyncio + async def test_get_matches_for_participant(self, db_session, sample_match, participant_user): + """Can retrieve all matches for participant""" + try: + match_service = MatchService(db_session) + + response = await match_service.get_matches_for_participant(participant_user.id) + + assert len(response.matches) == 1 + assert response.matches[0].participant_id == participant_user.id + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_get_matches_includes_volunteer_info(self, db_session, sample_match): + """Response includes volunteer name, email""" + try: + match_service = MatchService(db_session) + + response = await match_service.get_matches_for_participant(sample_match.participant_id) + + match = response.matches[0] + assert match.volunteer.first_name == "Test" + assert match.volunteer.last_name == "Volunteer" + assert match.volunteer.email == "volunteer@example.com" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_get_matches_includes_suggested_times(self, db_session, sample_match): + """Response includes suggested time blocks""" + try: + match_service = MatchService(db_session) + + response = await match_service.get_matches_for_participant(sample_match.participant_id) + + match = response.matches[0] + assert len(match.suggested_time_blocks) == 3 + # Verify sorted by start_time + times = [block.start_time for block in match.suggested_time_blocks] + assert times == sorted(times) + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_get_matches_empty_for_different_participant(self, db_session, sample_match, another_participant): + """Different participant gets empty list""" + try: + match_service = MatchService(db_session) + + response = await match_service.get_matches_for_participant(another_participant.id) + + assert len(response.matches) == 0 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_get_matches_ordered_by_created_at_desc( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """Matches returned in descending order by creation time""" + try: + match_service = MatchService(db_session) + + # Create two matches with slight delay (explicitly set to pending so participants can see them) + request1 = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + match_status="pending", + ) + await match_service.create_matches(request1) + + request2 = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[another_volunteer.id], + match_status="pending", + ) + await match_service.create_matches(request2) + + response = await match_service.get_matches_for_participant(participant_user.id) + + assert len(response.matches) == 2 + # Most recent first + assert response.matches[0].volunteer.email == "volunteer2@example.com" + assert response.matches[1].volunteer.email == "volunteer@example.com" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + +# ========== SCHEDULE MATCH TESTS ========== + + +class TestScheduleMatch: + """Test scheduling a match.""" + + @pytest.mark.asyncio + async def test_schedule_match_success(self, db_session, sample_match, participant_user): + """Participant can schedule a call""" + try: + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + response = await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + assert response.id == sample_match.id + assert response.chosen_time_block is not None + assert response.chosen_time_block.id == time_block_id + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_sets_chosen_time(self, db_session, sample_match, participant_user): + """Scheduling sets chosen_time_block_id""" + try: + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + # Refresh and verify + db_session.refresh(sample_match) + assert sample_match.chosen_time_block_id == time_block_id + assert sample_match.confirmed_time is not None + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_changes_status_to_confirmed(self, db_session, sample_match, participant_user): + """Status becomes 'confirmed'""" + try: + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + assert sample_match.match_status.name == "confirmed" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_deletes_other_matches( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """Scheduling one match soft-deletes other active matches""" + try: + match_service = MatchService(db_session) + + # Create 2 matches for same participant + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id, another_volunteer.id], + ) + response = await match_service.create_matches(request) + + match1_id = response.matches[0].id + match2_id = response.matches[1].id + + # Get a time block from match1 + match1 = db_session.get(Match, match1_id) + time_block_id = match1.suggested_time_blocks[0].id if match1.suggested_time_blocks else None + + # Need to add a time block if none exists + if not time_block_id: + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + block = TimeBlock(start_time=tomorrow.replace(hour=10, minute=0, second=0, microsecond=0)) + match1.suggested_time_blocks.append(block) + db_session.commit() + db_session.refresh(match1) + time_block_id = match1.suggested_time_blocks[0].id + + # Schedule match1 + await match_service.schedule_match(match1_id, time_block_id, acting_participant_id=participant_user.id) + + # Verify match1 is confirmed + match1 = db_session.get(Match, match1_id) + assert match1 is not None + assert match1.match_status.name == "confirmed" + + # Verify match2 is soft-deleted, not removed + match2 = db_session.get(Match, match2_id) + assert match2 is not None + assert match2.deleted_at is not None + + # Verify only 1 active match remains for participant + participant_matches = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.deleted_at.is_(None)) + .all() + ) + assert len(participant_matches) == 1 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_retains_historical_matches( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """Historical matches (e.g., completed) remain after scheduling a new match""" + try: + match_service = MatchService(db_session) + + # Create pending match with volunteer 1 + await match_service.create_matches( + MatchCreateRequest(participant_id=participant_user.id, volunteer_ids=[volunteer_user.id]) + ) + pending_match = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.deleted_at.is_(None)) + .order_by(Match.id) + .first() + ) + + if not pending_match.suggested_time_blocks: + kickoff = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + timedelta(days=1) + pending_match.suggested_time_blocks.append(TimeBlock(start_time=kickoff)) + db_session.commit() + db_session.refresh(pending_match) + + # Create second match and mark as completed + await match_service.create_matches( + MatchCreateRequest(participant_id=participant_user.id, volunteer_ids=[another_volunteer.id]) + ) + completed_match = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.id != pending_match.id) + .first() + ) + completed_status = db_session.query(MatchStatus).filter_by(name="completed").one() + completed_match.match_status = completed_status + db_session.commit() + + # Schedule the pending match + time_block_id = pending_match.suggested_time_blocks[0].id + await match_service.schedule_match( + pending_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + db_session.refresh(completed_match) + assert completed_match.deleted_at is None + assert completed_match.match_status.name == "completed" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_participant_ownership_check(self, db_session, sample_match, another_participant): + """403 when different participant tries to schedule""" + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + with pytest.raises(HTTPException) as exc_info: + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=another_participant.id + ) + + assert exc_info.value.status_code == 403 + assert "Cannot modify another participant" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_schedule_match_admin_can_bypass_ownership(self, db_session, sample_match): + """Admin can schedule on behalf (acting_participant_id=None)""" + try: + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + # Admin bypasses by passing None + response = await match_service.schedule_match(sample_match.id, time_block_id, acting_participant_id=None) + + assert response.match_status == "confirmed" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_invalid_time_block_404(self, db_session, sample_match, participant_user): + """404 when time block doesn't exist""" + match_service = MatchService(db_session) + + with pytest.raises(HTTPException) as exc_info: + await match_service.schedule_match(sample_match.id, 99999, acting_participant_id=participant_user.id) + + assert exc_info.value.status_code == 404 + assert "TimeBlock" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_schedule_match_invalid_match_404(self, db_session, participant_user): + """404 when match doesn't exist""" + match_service = MatchService(db_session) + + with pytest.raises(HTTPException) as exc_info: + await match_service.schedule_match(99999, 1, acting_participant_id=participant_user.id) + + assert exc_info.value.status_code == 404 + assert "Match" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_schedule_match_rejects_block_not_in_suggested_times( + self, db_session, sample_match, participant_user + ): + """400 when trying to book a time block not in match's suggested times""" + try: + match_service = MatchService(db_session) + + # Create a different time block not in sample_match's suggested times + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + other_block = TimeBlock(start_time=tomorrow.replace(hour=18, minute=0, second=0, microsecond=0)) + db_session.add(other_block) + db_session.commit() + db_session.refresh(other_block) + + # Try to schedule with this unauthorized block + with pytest.raises(HTTPException) as exc_info: + await match_service.schedule_match( + sample_match.id, other_block.id, acting_participant_id=participant_user.id + ) + + assert exc_info.value.status_code == 400 + assert "not available for this match" in exc_info.value.detail.lower() + + except HTTPException: + raise + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_schedule_match_prevents_double_booking_volunteer( + self, db_session, participant_user, another_participant, volunteer_user + ): + """409 when trying to book a volunteer at a time they're already confirmed""" + try: + match_service = MatchService(db_session) + + # Create two matches with same volunteer, different participants + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + same_time = tomorrow.replace(hour=14, minute=0, second=0, microsecond=0) + + # Match 1: Participant 1 + Volunteer + match1 = Match( + participant_id=participant_user.id, + volunteer_id=volunteer_user.id, + match_status_id=1, # pending + ) + db_session.add(match1) + db_session.flush() + + block1 = TimeBlock(start_time=same_time) + match1.suggested_time_blocks.append(block1) + + # Match 2: Participant 2 + Same Volunteer + match2 = Match( + participant_id=another_participant.id, + volunteer_id=volunteer_user.id, + match_status_id=1, # pending + ) + db_session.add(match2) + db_session.flush() + + block2 = TimeBlock(start_time=same_time) # Same time, different block instance! + match2.suggested_time_blocks.append(block2) + + db_session.commit() + db_session.refresh(match1) + db_session.refresh(match2) + + # Participant 1 schedules first + await match_service.schedule_match(match1.id, block1.id, acting_participant_id=participant_user.id) + + # Participant 2 tries to schedule same volunteer at same time + with pytest.raises(HTTPException) as exc_info: + await match_service.schedule_match(match2.id, block2.id, acting_participant_id=another_participant.id) + + assert exc_info.value.status_code == 409 + assert "already confirmed another appointment" in exc_info.value.detail.lower() + + except HTTPException: + raise + except Exception: + db_session.rollback() + raise + + +class TestVolunteerMatchFlow: + """Test volunteer-specific match flows.""" + + @pytest.mark.asyncio + async def test_get_matches_for_volunteer_includes_awaiting(self, db_session, participant_user, volunteer_user): + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + ) + await match_service.create_matches(request) + + response = await match_service.get_matches_for_volunteer(volunteer_user.id) + assert len(response.matches) == 1 + match_summary = response.matches[0] + assert match_summary.match_status == "awaiting_volunteer_acceptance" + assert match_summary.participant.id == participant_user.id + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_volunteer_accept_match_requires_ownership( + self, + db_session, + participant_user, + volunteer_with_availability, + another_volunteer, + ): + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_with_availability.id], + ) + response = await match_service.create_matches(request) + match_id = response.matches[0].id + + with pytest.raises(HTTPException) as exc_info: + await match_service.volunteer_accept_match(match_id, another_volunteer.id) + + assert exc_info.value.status_code == 403 + + detail = await match_service.volunteer_accept_match(match_id, volunteer_with_availability.id) + assert detail.match_status == "pending" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_volunteer_accept_match_requires_availability(self, db_session, participant_user, volunteer_user): + """Volunteer cannot accept match without availability.""" + try: + match_service = MatchService(db_session) + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + ) + response = await match_service.create_matches(request) + match_id = response.matches[0].id + + with pytest.raises(HTTPException) as exc_info: + await match_service.volunteer_accept_match(match_id, volunteer_user.id) + + assert exc_info.value.status_code == 400 + assert "availability" in exc_info.value.detail.lower() + + db_session.commit() + except Exception: + db_session.rollback() + raise + + +# ========== REQUEST NEW TIMES TESTS ========== + + +class TestRequestNewTimes: + """Test requesting new time windows.""" + + @pytest.mark.asyncio + async def test_request_new_times_success(self, db_session, sample_match, participant_user): + """Participant can request new time windows""" + try: + match_service = MatchService(db_session) + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=16, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=17, minute=0, second=0, microsecond=0), + ) + ] + + response = await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + assert response.id == sample_match.id + assert response.match_status == "requesting_new_times" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_times_clears_existing_suggestions(self, db_session, sample_match, participant_user): + """Old suggested times are deleted""" + try: + match_service = MatchService(db_session) + original_count = len(sample_match.suggested_time_blocks) + assert original_count == 3 + + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=16, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=17, minute=0, second=0, microsecond=0), + ) + ] + + await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + # Should have 2 new blocks (16:00, 16:30) + assert len(sample_match.suggested_time_blocks) == 2 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_times_generates_30_min_blocks(self, db_session, sample_match, participant_user): + """Creates blocks every 30 minutes""" + try: + match_service = MatchService(db_session) + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + # 2-hour window should generate 4 blocks + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=10, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=12, minute=0, second=0, microsecond=0), + ) + ] + + await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + assert len(sample_match.suggested_time_blocks) == 4 + + # Verify times: 10:00, 10:30, 11:00, 11:30 + times = sorted([block.start_time for block in sample_match.suggested_time_blocks]) + assert times[0].hour == 10 and times[0].minute == 0 + assert times[1].hour == 10 and times[1].minute == 30 + assert times[2].hour == 11 and times[2].minute == 0 + assert times[3].hour == 11 and times[3].minute == 30 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_times_changes_status(self, db_session, sample_match, participant_user): + """Status becomes 'requesting_new_times'""" + try: + match_service = MatchService(db_session) + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=16, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=17, minute=0, second=0, microsecond=0), + ) + ] + + await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + assert sample_match.match_status.name == "requesting_new_times" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_times_rejects_after_scheduled(self, db_session, sample_match, participant_user): + """400 when match already has chosen time""" + try: + match_service = MatchService(db_session) + + # First schedule the match + time_block_id = sample_match.suggested_time_blocks[0].id + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + # Now try to request new times + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=16, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=17, minute=0, second=0, microsecond=0), + ) + ] + + with pytest.raises(HTTPException) as exc_info: + await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + assert exc_info.value.status_code == 400 + assert "after a call is scheduled" in exc_info.value.detail.lower() + + except HTTPException: + raise + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_times_multiple_ranges(self, db_session, sample_match, participant_user): + """Can provide multiple time ranges""" + try: + match_service = MatchService(db_session) + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + time_ranges = [ + TimeRange( + start_time=tomorrow.replace(hour=10, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=11, minute=0, second=0, microsecond=0), + ), + TimeRange( + start_time=tomorrow.replace(hour=14, minute=0, second=0, microsecond=0), + end_time=tomorrow.replace(hour=15, minute=0, second=0, microsecond=0), + ), + ] + + await match_service.request_new_times( + sample_match.id, time_ranges, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + # Should have 4 blocks total (2 from each range) + assert len(sample_match.suggested_time_blocks) == 4 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + +# ========== CANCEL MATCH TESTS ========== + + +class TestCancelMatchByParticipant: + """Test participant canceling their match.""" + + @pytest.mark.asyncio + async def test_cancel_match_by_participant_success(self, db_session, sample_match, participant_user): + """Participant can cancel their match""" + try: + match_service = MatchService(db_session) + + response = await match_service.cancel_match_by_participant( + sample_match.id, acting_participant_id=participant_user.id + ) + + assert response.id == sample_match.id + assert response.match_status == "cancelled_by_participant" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_cancel_match_changes_status(self, db_session, sample_match, participant_user): + """Status becomes 'cancelled_by_participant'""" + try: + match_service = MatchService(db_session) + + await match_service.cancel_match_by_participant(sample_match.id, acting_participant_id=participant_user.id) + + db_session.refresh(sample_match) + assert sample_match.match_status.name == "cancelled_by_participant" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_cancel_match_clears_chosen_time(self, db_session, sample_match, participant_user): + """Chosen time block is cleared if it exists""" + try: + match_service = MatchService(db_session) + + # First schedule the match + time_block_id = sample_match.suggested_time_blocks[0].id + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + assert sample_match.chosen_time_block_id is not None + + # Now cancel + await match_service.cancel_match_by_participant(sample_match.id, acting_participant_id=participant_user.id) + + db_session.refresh(sample_match) + assert sample_match.chosen_time_block_id is None + assert sample_match.confirmed_time is None + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_cancel_match_ownership_check(self, db_session, sample_match, another_participant): + """403 when different participant tries to cancel""" + match_service = MatchService(db_session) + + with pytest.raises(HTTPException) as exc_info: + await match_service.cancel_match_by_participant( + sample_match.id, acting_participant_id=another_participant.id + ) + + assert exc_info.value.status_code == 403 + assert "Cannot modify another participant" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_cancel_match_admin_bypass(self, db_session, sample_match): + """Admin can cancel on behalf (acting_participant_id=None)""" + try: + match_service = MatchService(db_session) + + response = await match_service.cancel_match_by_participant(sample_match.id, acting_participant_id=None) + + assert response.match_status == "cancelled_by_participant" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + +class TestCancelMatchByVolunteer: + """Test volunteer canceling a match.""" + + @pytest.mark.asyncio + async def test_cancel_match_by_volunteer_success(self, db_session, sample_match, volunteer_user): + """Volunteer can cancel match""" + try: + match_service = MatchService(db_session) + + response = await match_service.cancel_match_by_volunteer( + sample_match.id, acting_volunteer_id=volunteer_user.id + ) + + assert response.id == sample_match.id + assert response.match_status == "cancelled_by_volunteer" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_cancel_by_volunteer_changes_status(self, db_session, sample_match): + """Status becomes 'cancelled_by_volunteer'""" + try: + match_service = MatchService(db_session) + + await match_service.cancel_match_by_volunteer( + sample_match.id, acting_volunteer_id=sample_match.volunteer_id + ) + + db_session.refresh(sample_match) + assert sample_match.match_status.name == "cancelled_by_volunteer" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_cancel_by_volunteer_ownership_check(self, db_session, sample_match, another_volunteer): + """403 when different volunteer tries to cancel""" + match_service = MatchService(db_session) + + with pytest.raises(HTTPException) as exc_info: + await match_service.cancel_match_by_volunteer(sample_match.id, acting_volunteer_id=another_volunteer.id) + + assert exc_info.value.status_code == 403 + assert "Cannot modify another volunteer" in exc_info.value.detail + + +# ========== REQUEST NEW VOLUNTEERS TESTS ========== + + +class TestRequestNewVolunteers: + """Test requesting new volunteer suggestions.""" + + @pytest.mark.asyncio + async def test_request_new_volunteers_deletes_matches( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """All participant matches are deleted""" + try: + match_service = MatchService(db_session) + + # Create 2 matches + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id, another_volunteer.id], + ) + await match_service.create_matches(request) + + # Verify 2 matches exist + matches = db_session.query(Match).filter(Match.participant_id == participant_user.id).all() + assert len(matches) == 2 + + # Request new volunteers + await match_service.request_new_volunteers(participant_user.id, acting_participant_id=participant_user.id) + + # Verify all active matches removed, but records remain soft-deleted + active_matches = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.deleted_at.is_(None)) + .all() + ) + assert len(active_matches) == 0 + + soft_deleted = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.deleted_at.isnot(None)) + .all() + ) + assert len(soft_deleted) == 2 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_volunteers_returns_count( + self, db_session, participant_user, volunteer_user, another_volunteer + ): + """Response includes number of deleted matches""" + try: + match_service = MatchService(db_session) + + # Create 2 matches + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id, another_volunteer.id], + ) + await match_service.create_matches(request) + + # Request new volunteers + response = await match_service.request_new_volunteers( + participant_user.id, acting_participant_id=participant_user.id + ) + + assert isinstance(response, MatchRequestNewVolunteersResponse) + assert response.deleted_matches == 2 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_volunteers_preserves_completed_matches( + self, db_session, participant_user, volunteer_user + ): + """Matches with final statuses should remain after requesting new volunteers""" + try: + match_service = MatchService(db_session) + + # Active match to be deleted + await match_service.create_matches( + MatchCreateRequest(participant_id=participant_user.id, volunteer_ids=[volunteer_user.id]) + ) + + # Historical completed match + completed_status = db_session.query(MatchStatus).filter_by(name="completed").one() + historical = Match( + participant_id=participant_user.id, + volunteer_id=volunteer_user.id, + match_status=completed_status, + ) + db_session.add(historical) + db_session.commit() + + await match_service.request_new_volunteers(participant_user.id, acting_participant_id=participant_user.id) + + db_session.refresh(historical) + assert historical.deleted_at is None + assert historical.match_status.name == "completed" + + active_matches = ( + db_session.query(Match) + .filter(Match.participant_id == participant_user.id, Match.deleted_at.is_(None)) + .all() + ) + assert all(m.match_status.name == "completed" for m in active_matches) + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_request_new_volunteers_ownership_check(self, db_session, participant_user, another_participant): + """403 when different participant tries""" + match_service = MatchService(db_session) + + with pytest.raises(HTTPException) as exc_info: + await match_service.request_new_volunteers( + participant_user.id, acting_participant_id=another_participant.id + ) + + assert exc_info.value.status_code == 403 + assert "Cannot modify another participant" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_request_new_volunteers_admin_on_behalf(self, db_session, participant_user, volunteer_user): + """Admin can request for specific participant (acting_participant_id=None)""" + try: + match_service = MatchService(db_session) + + # Create a match + request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_user.id], + ) + await match_service.create_matches(request) + + # Admin requests new volunteers (bypasses ownership check) + response = await match_service.request_new_volunteers(participant_user.id, acting_participant_id=None) + + assert response.deleted_matches == 1 + + db_session.commit() + except Exception: + db_session.rollback() + raise + + +# ========== UPDATE MATCH TESTS ========== + + +class TestUpdateMatch: + """Test admin updating match properties.""" + + @pytest.mark.asyncio + async def test_update_match_volunteer(self, db_session, sample_match, another_volunteer): + """Admin can change match volunteer""" + try: + match_service = MatchService(db_session) + + update_request = MatchUpdateRequest(volunteer_id=another_volunteer.id) + + response = await match_service.update_match(sample_match.id, update_request) + + assert response.volunteer_id == another_volunteer.id + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_update_match_status(self, db_session, sample_match): + """Admin can change match status""" + try: + match_service = MatchService(db_session) + + update_request = MatchUpdateRequest(match_status="completed") + + response = await match_service.update_match(sample_match.id, update_request) + + assert response.match_status == "completed" + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_update_match_set_chosen_time(self, db_session, sample_match): + """Admin can set chosen time""" + try: + match_service = MatchService(db_session) + time_block_id = sample_match.suggested_time_blocks[0].id + + update_request = MatchUpdateRequest(chosen_time_block_id=time_block_id) + + response = await match_service.update_match(sample_match.id, update_request) + + assert response.chosen_time_block_id == time_block_id + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_update_match_clear_chosen_time(self, db_session, sample_match, participant_user): + """Admin can clear chosen time""" + try: + match_service = MatchService(db_session) + + # First set a chosen time + time_block_id = sample_match.suggested_time_blocks[0].id + await match_service.schedule_match( + sample_match.id, time_block_id, acting_participant_id=participant_user.id + ) + + db_session.refresh(sample_match) + assert sample_match.chosen_time_block_id is not None + + # Now clear it + update_request = MatchUpdateRequest(clear_chosen_time=True) + response = await match_service.update_match(sample_match.id, update_request) + + assert response.chosen_time_block_id is None + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio + async def test_update_match_invalid_match_404(self, db_session): + """404 when match doesn't exist""" + match_service = MatchService(db_session) + + update_request = MatchUpdateRequest(match_status="completed") + + with pytest.raises(HTTPException) as exc_info: + await match_service.update_match(99999, update_request) + + assert exc_info.value.status_code == 404 + assert "Match" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_update_match_reassigns_volunteer_resets_suggested_times( + self, + db_session, + participant_user, + volunteer_with_availability, + volunteer_with_alt_availability, + ): + """Changing the volunteer regenerates suggested times and clears chosen slot""" + try: + match_service = MatchService(db_session) + + # Create match with first volunteer and schedule it + create_request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer_with_availability.id], + ) + response = await match_service.create_matches(create_request) + match_id = response.matches[0].id + + match = db_session.get(Match, match_id) + await match_service.volunteer_accept_match(match_id, volunteer_with_availability.id) + db_session.refresh(match) + assert len(match.suggested_time_blocks) > 0 + + time_block_id = match.suggested_time_blocks[0].id + await match_service.schedule_match(match_id, time_block_id, acting_participant_id=participant_user.id) + + db_session.refresh(match) + assert match.match_status.name == "confirmed" + assert match.chosen_time_block_id is not None + + # Reassign to alternate volunteer + update_request = MatchUpdateRequest(volunteer_id=volunteer_with_alt_availability.id) + await match_service.update_match(match_id, update_request) + + db_session.refresh(match) + assert match.volunteer_id == volunteer_with_alt_availability.id + assert match.chosen_time_block_id is None + assert match.match_status.name == "awaiting_volunteer_acceptance" + assert len(match.suggested_time_blocks) == 0 + + await match_service.volunteer_accept_match(match_id, volunteer_with_alt_availability.id) + db_session.refresh(match) + assert match.match_status.name == "pending" + + starts = sorted(block.start_time for block in match.suggested_time_blocks) + expected = sorted([slot.start_time for slot in volunteer_with_alt_availability.availability]) + assert starts == expected + + db_session.commit() + except Exception: + db_session.rollback() + raise diff --git a/backend/tests/unit/test_time_block_validation.py b/backend/tests/unit/test_time_block_validation.py new file mode 100644 index 00000000..b934087e --- /dev/null +++ b/backend/tests/unit/test_time_block_validation.py @@ -0,0 +1,205 @@ +"""Unit tests for TimeRange schema validation. + +Tests the TimeRange Pydantic model validation rules: +- Times must be on half-hour boundaries (:00 or :30) +- End time must be after start time +- Minimum duration of 30 minutes +- No seconds or microseconds allowed +""" + +from datetime import datetime, timedelta + +import pytest +from pydantic import ValidationError + +from app.schemas.time_block import TimeRange + + +class TestTimeRangeValidation: + """Test TimeRange schema validation without database""" + + # ========== VALID CASES ========== + + def test_valid_30_minute_window(self): + """Valid: Exactly 30 minutes (10:00-10:30)""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 10, 30, 0), + ) + assert tr.start_time.hour == 10 + assert tr.start_time.minute == 0 + assert tr.end_time.hour == 10 + assert tr.end_time.minute == 30 + + def test_valid_1_hour_window(self): + """Valid: 1 hour window (10:00-11:00)""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 11, 0, 0), + ) + assert tr.start_time.hour == 10 + assert tr.end_time.hour == 11 + + def test_valid_2_hour_window(self): + """Valid: 2 hours (10:00-12:00)""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 12, 0, 0), + ) + assert (tr.end_time - tr.start_time) == timedelta(hours=2) + + def test_valid_half_hour_start_and_end(self): + """Valid: Both on half-hour (10:30-11:30)""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 30, 0), + end_time=datetime(2025, 10, 15, 11, 30, 0), + ) + assert tr.start_time.minute == 30 + assert tr.end_time.minute == 30 + + def test_valid_90_minute_window(self): + """Valid: 90 minutes (10:00-11:30)""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 11, 30, 0), + ) + assert (tr.end_time - tr.start_time) == timedelta(minutes=90) + + # ========== INVALID: MINIMUM DURATION ========== + + def test_reject_29_minute_window(self): + """Invalid: 29 minutes is too short""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 10, 29, 0), + ) + # Should fail on half-hour boundary check (10:29 not valid) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_20_minute_window(self): + """Invalid: 20 minutes is too short""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 10, 20, 0), + ) + assert "error" in str(exc_info.value).lower() + + def test_reject_exact_30_minute_window_with_invalid_end(self): + """Invalid: 30 minutes but end not on half-hour (10:00-10:30:00 would work, but 10:00-10:29:59 fails)""" + # This actually tests 29:59 which should fail on boundary check + end_time = datetime(2025, 10, 15, 10, 0, 0) + timedelta(minutes=29, seconds=59) + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=end_time, + ) + # Should fail because end has seconds + assert "error" in str(exc_info.value).lower() + + # ========== INVALID: BOUNDARIES ========== + + def test_reject_15_minute_start(self): + """Invalid: Start time at :15""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 15, 0), + end_time=datetime(2025, 10, 15, 11, 0, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_45_minute_start(self): + """Invalid: Start time at :45""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 45, 0), + end_time=datetime(2025, 10, 15, 11, 30, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_15_minute_end(self): + """Invalid: End time at :15""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 11, 15, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_45_minute_end(self): + """Invalid: End time at :45""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 30, 0), + end_time=datetime(2025, 10, 15, 11, 45, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + # ========== INVALID: TIME PRECISION ========== + + def test_reject_start_with_seconds(self): + """Invalid: Start time has seconds""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 30), + end_time=datetime(2025, 10, 15, 11, 0, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_end_with_seconds(self): + """Invalid: End time has seconds""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 11, 0, 15), + ) + assert "half hour" in str(exc_info.value).lower() + + def test_reject_with_microseconds(self): + """Invalid: Time has microseconds""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0, 500000), # 500ms + end_time=datetime(2025, 10, 15, 11, 0, 0), + ) + assert "half hour" in str(exc_info.value).lower() + + # ========== INVALID: LOGIC ========== + + def test_reject_end_before_start(self): + """Invalid: End time before start time""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 11, 0, 0), + end_time=datetime(2025, 10, 15, 10, 0, 0), + ) + assert "after start_time" in str(exc_info.value).lower() + + def test_reject_end_equals_start(self): + """Invalid: End time equals start time""" + with pytest.raises(ValidationError) as exc_info: + TimeRange( + start_time=datetime(2025, 10, 15, 10, 0, 0), + end_time=datetime(2025, 10, 15, 10, 0, 0), + ) + assert "after start_time" in str(exc_info.value).lower() + + # ========== EDGE CASES ========== + + def test_valid_across_midnight(self): + """Valid: Time range crossing midnight""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 23, 30, 0), + end_time=datetime(2025, 10, 16, 0, 30, 0), + ) + assert tr.start_time.day == 15 + assert tr.end_time.day == 16 + + def test_valid_exactly_30_minutes_on_half_hour(self): + """Valid: Exactly 30 minutes starting on :30""" + tr = TimeRange( + start_time=datetime(2025, 10, 15, 10, 30, 0), + end_time=datetime(2025, 10, 15, 11, 0, 0), + ) + assert (tr.end_time - tr.start_time) == timedelta(minutes=30) 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. -

-
- -
- -