Skip to content

Commit bb04542

Browse files
YashK2005UmairHundekar
authored andcommitted
Participant dash backend and start of frontend (#72)
# Participant Dashboard Frontend ## Overview This PR implements a comprehensive participant dashboard that allows participants to view their volunteer matches, manage match statuses, and request new volunteers. It includes both frontend UI components and backend API endpoints to support the full match management workflow. ## Features ### Frontend - **Participant Dashboard** (`/participant/dashboard`) - Landing page with sidebar navigation (Matches, Contact tabs) - Dynamic header messages based on match state - User avatar display in header - **Match Display Components** - `VolunteerCard`: Displays pending matches with volunteer details (name, pronouns, age, timezone, diagnosis, overview, treatments, experiences) - `ConfirmedMatchCard`: Displays confirmed/scheduled matches with timeline layout (date badge, time, vertical teal bar, volunteer info) - Both cards show volunteer overview from `volunteer_data` table - **Request New Matches Flow** - Inline form when all matches are completed (no card border, heading in header area) - Modal flow for requesting new matches from pending matches - Success confirmation modal after submission - Form includes optional message/notes field - **Date/Time Utilities** - `formatDateRelative()`: "Today", "Tomorrow", or day of week - `formatDateShort()`: "Feb 26" format - `formatTime()`: "12:00PM" format ### Backend - **Match Management APIs** - `GET /matches/me`: Get all matches for current participant with volunteer details - `POST /matches/request-new-volunteers`: Request new volunteers (soft-deletes active matches, creates task, sets pending flag) - `POST /matches/{match_id}/schedule`: Schedule a match (participant selects time) - `POST /matches/{match_id}/cancel-participant`: Cancel match as participant - `POST /matches/{match_id}/cancel-volunteer`: Cancel match as volunteer - `POST /matches/{match_id}/request-new-times`: Request new time options - `POST /matches/{match_id}/accept`: Volunteer accepts a match - **Match Service Enhancements** - Soft deletion support (`deleted_at` column) - Match status management with cleanup logic - Volunteer summary includes timezone and overview from `user_data` and `volunteer_data` tables - Prevents double-booking volunteers - Handles match state transitions (pending → confirmed → completed) - **Database Changes** - Added `deleted_at` column to `matches` table (soft deletion) - Added `pending_volunteer_request` boolean field to `users` table - Added `description` column to `tasks` table - **Time Block Validation** - Updated `TimeRange` to accept half-hour boundaries - All time slots must start at 0 or 30 minutes, be 30 minutes in length ## Database Migrations - `d87b1000d48b`: Add soft deletion to matches table - `14fdeccc883f`: Add pending_volunteer_request field to users table - `e3f0a5b4b7c4`: Add task description column ## TODOs - Schedule flow not yet implemented (placeholder handler) - Cancel call flow not yet implemented (placeholder handler) - View contact details flow not yet implemented (placeholder handler) - Contact tab shows placeholder message - Mobile styling - Edit profile pages not yet implemented
1 parent cd69526 commit bb04542

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+4879
-195
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@
99
**/*.cache
1010
**/*.egg-info
1111
**/test.db
12-
.cursor/
12+
.cursor/
13+
.claude/
14+
.codex/
15+
.mcp.json

backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ local_settings.py
6161
db.sqlite3
6262
db.sqlite3-journal
6363

64+
# Test databases
65+
*.db
66+
6467
# Flask stuff:
6568
instance/
6669
.webassets-cache

backend/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ To apply the migration, run the following command:
152152
pdm run alembic upgrade head
153153
```
154154
155+
## Testing
156+
157+
### First Time Setup
158+
```bash
159+
pdm run test-setup # Creates test database, runs migrations, seeds data
160+
```
161+
162+
### Run Tests
163+
```bash
164+
pdm run tests
165+
```
166+
155167
### Logging
156168
157169
To add a logger to a new service or file, use the `LOGGER_NAME` function in `app/utilities/constants.py`

backend/app/models/Match.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Match(Base):
2121

2222
created_at = Column(DateTime(timezone=True), server_default=func.now())
2323
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
24+
deleted_at = Column(DateTime(timezone=True), nullable=True)
2425

2526
match_status = relationship("MatchStatus")
2627

backend/app/models/Task.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime
33
from enum import Enum as PyEnum
44

5-
from sqlalchemy import Column, DateTime, ForeignKey
5+
from sqlalchemy import Column, DateTime, ForeignKey, Text
66
from sqlalchemy import Enum as SQLEnum
77
from sqlalchemy.dialects.postgresql import UUID
88
from sqlalchemy.orm import relationship
@@ -69,6 +69,7 @@ class Task(Base):
6969
end_date = Column(DateTime, nullable=True)
7070
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
7171
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
72+
description = Column(Text, nullable=True)
7273

7374
# Relationships
7475
participant = relationship("User", foreign_keys=[participant_id], backref="participant_tasks")

backend/app/models/User.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class User(Base):
3131
auth_id = Column(Text, nullable=False)
3232
approved = Column(Boolean, default=False)
3333
active = Column(Boolean, nullable=False, default=True)
34+
pending_volunteer_request = Column(Boolean, nullable=False, default=False)
3435
form_status = Column(
3536
SQLEnum(
3637
FormStatus,
@@ -52,3 +53,5 @@ class User(Base):
5253
volunteer_matches = relationship("Match", back_populates="volunteer", foreign_keys=[Match.volunteer_id])
5354

5455
volunteer_data = relationship("VolunteerData", back_populates="user", uselist=False)
56+
57+
user_data = relationship("UserData", back_populates="user", uselist=False)

backend/app/models/UserData.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,6 @@ class UserData(Base):
8989
# Loved one many-to-many relationships
9090
loved_one_treatments = relationship("Treatment", secondary=user_loved_one_treatments)
9191
loved_one_experiences = relationship("Experience", secondary=user_loved_one_experiences)
92+
93+
# Back-reference to User
94+
user = relationship("User", back_populates="user_data")

backend/app/routes/match.py

Lines changed: 269 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1-
from fastapi import APIRouter, Depends, HTTPException
1+
from typing import Optional
2+
from uuid import UUID
3+
4+
from fastapi import APIRouter, Depends, HTTPException, Request
25
from sqlalchemy.orm import Session
36

4-
from app.schemas.match import SubmitMatchRequest, SubmitMatchResponse
7+
from app.middleware.auth import has_roles
8+
from app.schemas.match import (
9+
MatchCreateRequest,
10+
MatchCreateResponse,
11+
MatchDetailResponse,
12+
MatchListForVolunteerResponse,
13+
MatchListResponse,
14+
MatchRequestNewTimesRequest,
15+
MatchRequestNewVolunteersRequest,
16+
MatchRequestNewVolunteersResponse,
17+
MatchResponse,
18+
MatchScheduleRequest,
19+
MatchUpdateRequest,
20+
)
21+
from app.schemas.task import TaskCreateRequest, TaskType
22+
from app.schemas.user import UserRole
523
from app.services.implementations.match_service import MatchService
24+
from app.services.implementations.task_service import TaskService
25+
from app.services.implementations.user_service import UserService
626
from app.utilities.db_utils import get_db
27+
from app.utilities.service_utils import get_task_service, get_user_service
728

829
router = APIRouter(prefix="/matches", tags=["matches"])
930

@@ -12,16 +33,256 @@ def get_match_service(db: Session = Depends(get_db)) -> MatchService:
1233
return MatchService(db)
1334

1435

15-
@router.post("/confirm-time", response_model=SubmitMatchResponse)
16-
async def confirm_time(
17-
payload: SubmitMatchRequest,
36+
@router.post("/", response_model=MatchCreateResponse)
37+
async def create_matches(
38+
payload: MatchCreateRequest,
39+
match_service: MatchService = Depends(get_match_service),
40+
_authorized: bool = has_roles([UserRole.ADMIN]),
41+
):
42+
try:
43+
return await match_service.create_matches(payload)
44+
except HTTPException as http_ex:
45+
raise http_ex
46+
except Exception as e:
47+
raise HTTPException(status_code=500, detail=str(e))
48+
49+
50+
@router.put("/{match_id}", response_model=MatchResponse)
51+
async def update_match(
52+
match_id: int,
53+
payload: MatchUpdateRequest,
54+
match_service: MatchService = Depends(get_match_service),
55+
_authorized: bool = has_roles([UserRole.ADMIN]),
56+
):
57+
try:
58+
return await match_service.update_match(match_id, payload)
59+
except HTTPException as http_ex:
60+
raise http_ex
61+
except Exception as e:
62+
raise HTTPException(status_code=500, detail=str(e))
63+
64+
65+
@router.get("/me", response_model=MatchListResponse)
66+
async def get_my_matches(
67+
request: Request,
1868
match_service: MatchService = Depends(get_match_service),
69+
user_service: UserService = Depends(get_user_service),
70+
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
1971
):
2072
try:
21-
confirmed_match = await match_service.submit_time(payload)
22-
return confirmed_match
73+
auth_id = getattr(request.state, "user_id", None)
74+
if not auth_id:
75+
raise HTTPException(status_code=401, detail="Unauthorized")
76+
77+
participant_id_str = await user_service.get_user_id_by_auth_id(auth_id)
78+
participant_id = UUID(participant_id_str)
79+
return await match_service.get_matches_for_participant(participant_id)
2380
except HTTPException as http_ex:
2481
raise http_ex
2582
except Exception as e:
26-
print(e)
2783
raise HTTPException(status_code=500, detail=str(e))
84+
85+
86+
@router.get("/participant/{participant_id}", response_model=MatchListResponse)
87+
async def get_matches_for_participant(
88+
participant_id: UUID,
89+
match_service: MatchService = Depends(get_match_service),
90+
_authorized: bool = has_roles([UserRole.ADMIN]),
91+
):
92+
try:
93+
return await match_service.get_matches_for_participant(participant_id)
94+
except HTTPException as http_ex:
95+
raise http_ex
96+
except Exception as e:
97+
raise HTTPException(status_code=500, detail=str(e))
98+
99+
100+
@router.post("/{match_id}/schedule", response_model=MatchDetailResponse)
101+
async def schedule_match(
102+
match_id: int,
103+
payload: MatchScheduleRequest,
104+
request: Request,
105+
match_service: MatchService = Depends(get_match_service),
106+
user_service: UserService = Depends(get_user_service),
107+
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
108+
):
109+
try:
110+
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
111+
return await match_service.schedule_match(match_id, payload.time_block_id, acting_participant_id)
112+
except HTTPException as http_ex:
113+
raise http_ex
114+
except Exception as e:
115+
raise HTTPException(status_code=500, detail=str(e))
116+
117+
118+
@router.post("/{match_id}/request-new-times", response_model=MatchDetailResponse)
119+
async def request_new_times(
120+
match_id: int,
121+
payload: MatchRequestNewTimesRequest,
122+
request: Request,
123+
match_service: MatchService = Depends(get_match_service),
124+
user_service: UserService = Depends(get_user_service),
125+
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
126+
):
127+
try:
128+
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
129+
return await match_service.request_new_times(match_id, payload.suggested_new_times, acting_participant_id)
130+
except HTTPException as http_ex:
131+
raise http_ex
132+
except Exception as e:
133+
raise HTTPException(status_code=500, detail=str(e))
134+
135+
136+
@router.post("/{match_id}/cancel", response_model=MatchDetailResponse)
137+
async def cancel_match_as_participant(
138+
match_id: int,
139+
request: Request,
140+
match_service: MatchService = Depends(get_match_service),
141+
user_service: UserService = Depends(get_user_service),
142+
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
143+
):
144+
try:
145+
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
146+
return await match_service.cancel_match_by_participant(match_id, acting_participant_id)
147+
except HTTPException as http_ex:
148+
raise http_ex
149+
except Exception as e:
150+
raise HTTPException(status_code=500, detail=str(e))
151+
152+
153+
@router.get("/volunteer/me", response_model=MatchListForVolunteerResponse)
154+
async def get_my_matches_as_volunteer(
155+
request: Request,
156+
match_service: MatchService = Depends(get_match_service),
157+
user_service: UserService = Depends(get_user_service),
158+
_authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]),
159+
):
160+
"""Get all matches for the current volunteer, including those awaiting acceptance."""
161+
try:
162+
auth_id = getattr(request.state, "user_id", None)
163+
if not auth_id:
164+
raise HTTPException(status_code=401, detail="Unauthorized")
165+
166+
volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id)
167+
volunteer_id = UUID(volunteer_id_str)
168+
return await match_service.get_matches_for_volunteer(volunteer_id)
169+
except HTTPException as http_ex:
170+
raise http_ex
171+
except Exception as e:
172+
raise HTTPException(status_code=500, detail=str(e))
173+
174+
175+
@router.post("/{match_id}/accept-volunteer", response_model=MatchDetailResponse)
176+
async def accept_match_as_volunteer(
177+
match_id: int,
178+
request: Request,
179+
match_service: MatchService = Depends(get_match_service),
180+
user_service: UserService = Depends(get_user_service),
181+
_authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]),
182+
):
183+
"""Volunteer accepts a match and sends their general availability to participant."""
184+
try:
185+
acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service)
186+
return await match_service.volunteer_accept_match(match_id, acting_volunteer_id)
187+
except HTTPException as http_ex:
188+
raise http_ex
189+
except Exception as e:
190+
raise HTTPException(status_code=500, detail=str(e))
191+
192+
193+
@router.post("/{match_id}/cancel-volunteer", response_model=MatchDetailResponse)
194+
async def cancel_match_as_volunteer(
195+
match_id: int,
196+
request: Request,
197+
match_service: MatchService = Depends(get_match_service),
198+
user_service: UserService = Depends(get_user_service),
199+
_authorized: bool = has_roles([UserRole.ADMIN, UserRole.VOLUNTEER]),
200+
):
201+
try:
202+
acting_volunteer_id = await _resolve_acting_volunteer_id(request, user_service)
203+
return await match_service.cancel_match_by_volunteer(match_id, acting_volunteer_id)
204+
except HTTPException as http_ex:
205+
raise http_ex
206+
except Exception as e:
207+
raise HTTPException(status_code=500, detail=str(e))
208+
209+
210+
@router.post("/request-new-volunteers", response_model=MatchRequestNewVolunteersResponse)
211+
async def request_new_volunteers(
212+
request: Request,
213+
payload: MatchRequestNewVolunteersRequest,
214+
match_service: MatchService = Depends(get_match_service),
215+
user_service: UserService = Depends(get_user_service),
216+
task_service: TaskService = Depends(get_task_service),
217+
_authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
218+
):
219+
try:
220+
participant_id = payload.participant_id
221+
222+
if participant_id is None:
223+
participant_id = await _resolve_acting_participant_id(request, user_service)
224+
if not participant_id:
225+
raise HTTPException(status_code=400, detail="Participant identity required")
226+
response = await match_service.request_new_volunteers(participant_id, participant_id)
227+
else:
228+
acting_participant_id = await _resolve_acting_participant_id(request, user_service)
229+
response = await match_service.request_new_volunteers(participant_id, acting_participant_id)
230+
task_request = TaskCreateRequest(
231+
participant_id=participant_id,
232+
type=TaskType.MATCHING,
233+
description=payload.message,
234+
)
235+
await task_service.create_task(task_request)
236+
237+
return response
238+
except HTTPException as http_ex:
239+
raise http_ex
240+
except Exception as e:
241+
raise HTTPException(status_code=500, detail=str(e))
242+
243+
244+
async def _resolve_acting_participant_id(request: Request, user_service: UserService) -> Optional[UUID]:
245+
auth_id = getattr(request.state, "user_id", None)
246+
if not auth_id:
247+
raise HTTPException(status_code=401, detail="Authentication required")
248+
249+
try:
250+
role_name = user_service.get_user_role_by_auth_id(auth_id)
251+
except ValueError as exc:
252+
raise HTTPException(status_code=401, detail="User not found") from exc
253+
254+
if role_name == UserRole.PARTICIPANT.value:
255+
try:
256+
participant_id_str = await user_service.get_user_id_by_auth_id(auth_id)
257+
except ValueError as exc:
258+
raise HTTPException(status_code=404, detail="Participant not found") from exc
259+
return UUID(participant_id_str)
260+
261+
if role_name == UserRole.ADMIN.value:
262+
# Admin callers bypass ownership checks
263+
return None
264+
265+
raise HTTPException(status_code=403, detail="Insufficient role for participant operation")
266+
267+
268+
async def _resolve_acting_volunteer_id(request: Request, user_service: UserService) -> Optional[UUID]:
269+
auth_id = getattr(request.state, "user_id", None)
270+
if not auth_id:
271+
raise HTTPException(status_code=401, detail="Authentication required")
272+
273+
try:
274+
role_name = user_service.get_user_role_by_auth_id(auth_id)
275+
except ValueError as exc:
276+
raise HTTPException(status_code=401, detail="User not found") from exc
277+
278+
if role_name == UserRole.VOLUNTEER.value:
279+
try:
280+
volunteer_id_str = await user_service.get_user_id_by_auth_id(auth_id)
281+
except ValueError as exc:
282+
raise HTTPException(status_code=404, detail="Volunteer not found") from exc
283+
return UUID(volunteer_id_str)
284+
285+
if role_name == UserRole.ADMIN.value:
286+
return None
287+
288+
raise HTTPException(status_code=403, detail="Insufficient role for volunteer operation")

0 commit comments

Comments
 (0)