Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
99b48f3
update form_status to secondary_application_todo after submitting sec…
YashK2005 Oct 11, 2025
31eccf6
update match_statuses
YashK2005 Oct 12, 2025
d61fc80
Add match management and participant APIs for matches
YashK2005 Oct 13, 2025
3108285
Add participant scheduling APIs and updated some time block handling
YashK2005 Oct 14, 2025
938925b
Add cancellation APIs for participants and volunteers cancelling matc…
YashK2005 Oct 14, 2025
9f67cdd
Update TimeRange so that it accepts half-hour boundaires, updated it …
YashK2005 Oct 14, 2025
d712641
cleanup some old code + linted backend
YashK2005 Oct 15, 2025
00b8a37
Add unit tests for the new APIs, created a pdm command for setting up…
YashK2005 Oct 15, 2025
f9a68f5
Remove test database files from tracking
YashK2005 Oct 15, 2025
cf3dfbe
addressing codex comments -- add soft deleting for matches and preven…
YashK2005 Oct 15, 2025
7fe2fa9
run backend linter
YashK2005 Oct 15, 2025
25c638f
add better error handling for resolving participants and volunteers +…
YashK2005 Oct 15, 2025
3508ce2
one last fix (hopefully last one)
YashK2005 Oct 15, 2025
e2efcbd
Add volunteer match acceptance flow
YashK2005 Nov 4, 2025
6706af6
lint
YashK2005 Nov 4, 2025
440e026
started participant frontend: landing page with volunteer cards and r…
YashK2005 Nov 10, 2025
5cd3ee7
landing page for cases where there is a confirmed match and a complet…
YashK2005 Nov 10, 2025
318a2a2
fetch timezone for the volunteer cards from user_data
YashK2005 Nov 11, 2025
0b942bc
lint
YashK2005 Nov 12, 2025
1c86444
prettier
YashK2005 Nov 12, 2025
d89938e
one more
YashK2005 Nov 12, 2025
8b3bc47
fix
YashK2005 Nov 12, 2025
a4b22c1
merge migration heads
YashK2005 Nov 13, 2025
21514fa
backend lint
YashK2005 Nov 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
**/*.cache
**/*.egg-info
**/test.db
.cursor/
.cursor/
.claude/
.codex/
.mcp.json
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal

# Test databases
*.db

# Flask stuff:
instance/
.webassets-cache
Expand Down
12 changes: 12 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/Match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/Task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/User.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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,
Expand All @@ -52,3 +53,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)
3 changes: 3 additions & 0 deletions backend/app/models/UserData.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,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")
277 changes: 269 additions & 8 deletions backend/app/routes/match.py
Original file line number Diff line number Diff line change
@@ -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"])

Expand All @@ -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")
Loading