Skip to content

Commit f4974b2

Browse files
committed
wired up opt out buttons for participant and volunteer dashboard. implemented become participant and become volunteer forms from the volunteer adn participant dashboard. on the admin side, they can view/edit/reject/approve these forms or also create them on behalf of users
1 parent 5b55858 commit f4974b2

File tree

23 files changed

+1318
-186
lines changed

23 files changed

+1318
-186
lines changed

backend/app/routes/intake.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,27 @@ async def create_form_submission(
162162
form_name_mapping = {
163163
"participant": "First Connection Participant Form",
164164
"volunteer": "First Connection Volunteer Form",
165+
"become_participant": "Become a Participant Form",
166+
"become_volunteer": "Become a Volunteer Form",
165167
}
166168

167169
form_name = form_name_mapping.get(effective_form_type)
168170
if not form_name:
169171
raise HTTPException(
170172
status_code=400,
171-
detail=f"Invalid formType: {effective_form_type}. Must be 'participant' or 'volunteer'",
173+
detail=f"Invalid formType: {effective_form_type}. Must be 'participant', 'volunteer', 'become_participant', or 'become_volunteer'",
172174
)
173175

174-
# Find the form
175-
form = db.query(Form).filter(Form.type == "intake", Form.name == form_name).first()
176+
# Find the form - for become_* forms, look by type
177+
if effective_form_type in ["become_participant", "become_volunteer"]:
178+
form_type = effective_form_type
179+
form = db.query(Form).filter(Form.type == form_type, Form.name == form_name).first()
180+
else:
181+
# Original intake forms
182+
form = db.query(Form).filter(Form.type == "intake", Form.name == form_name).first()
176183

177184
if not form:
178-
raise HTTPException(status_code=500, detail=f"Intake form '{form_name}' not found in database")
185+
raise HTTPException(status_code=500, detail=f"Form '{form_name}' not found in database")
179186
form_id = form.id
180187
else:
181188
# Verify the form exists and is of type 'intake'
@@ -190,21 +197,47 @@ async def create_form_submission(
190197

191198
# Enforce role-to-form access (admin exempt)
192199
if current_user.role.name != "admin":
193-
if current_user.role.name == "volunteer" and effective_form_type != "volunteer":
194-
raise HTTPException(status_code=403, detail="Volunteers can only submit the volunteer intake form")
195-
if current_user.role.name == "participant" and effective_form_type != "participant":
196-
raise HTTPException(status_code=403, detail="Participants can only submit the participant intake form")
197-
198-
# Create the raw form submission record with pending status
199-
db_submission = FormSubmission(
200-
form_id=form_id,
201-
user_id=target_user.id,
202-
answers=submission.answers,
203-
status="pending_approval",
204-
)
200+
if current_user.role.name == "volunteer" and effective_form_type not in ["volunteer", "become_participant"]:
201+
raise HTTPException(
202+
status_code=403,
203+
detail="Volunteers can only submit the volunteer intake form or become participant form",
204+
)
205+
if current_user.role.name == "participant" and effective_form_type not in [
206+
"participant",
207+
"become_volunteer",
208+
]:
209+
raise HTTPException(
210+
status_code=403,
211+
detail="Participants can only submit the participant intake form or become volunteer form",
212+
)
205213

206-
db.add(db_submission)
207-
db.flush() # Get the submission ID without committing
214+
# For role-change forms, ensure only one submission exists per user
215+
existing_submission = None
216+
if form and form.type in ["become_participant", "become_volunteer"]:
217+
existing_submission = (
218+
db.query(FormSubmission)
219+
.filter(
220+
FormSubmission.user_id == target_user.id,
221+
FormSubmission.form_id == form.id,
222+
)
223+
.order_by(FormSubmission.submitted_at.desc())
224+
.first()
225+
)
226+
227+
if existing_submission:
228+
existing_submission.answers = submission.answers
229+
existing_submission.status = "pending_approval"
230+
existing_submission.submitted_at = datetime.utcnow()
231+
db_submission = existing_submission
232+
else:
233+
db_submission = FormSubmission(
234+
form_id=form_id,
235+
user_id=target_user.id,
236+
answers=submission.answers,
237+
status="pending_approval",
238+
)
239+
db.add(db_submission)
240+
db.flush() # Get the submission ID without committing
208241

209242
# For intake forms: Update essential fields on User and set form_status
210243
# Full processing to UserData happens on admin approval

backend/app/routes/user.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import Optional
22

3-
from fastapi import APIRouter, Depends, HTTPException, Query
3+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
4+
from sqlalchemy.orm import Session
45

56
from app.middleware.auth import has_roles
7+
from app.models.User import User
68
from app.schemas.user import (
79
UserCreateRequest,
810
UserCreateResponse,
@@ -13,6 +15,7 @@
1315
)
1416
from app.schemas.user_data import UserDataUpdateRequest
1517
from app.services.implementations.user_service import UserService
18+
from app.utilities.db_utils import get_db
1619
from app.utilities.service_utils import get_user_service
1720

1821
router = APIRouter(
@@ -59,14 +62,29 @@ async def get_users(
5962
raise HTTPException(status_code=500, detail=str(e))
6063

6164

62-
# admin only get user by ID
65+
# get user by ID (admin or self)
6366
@router.get("/{user_id}", response_model=UserResponse)
6467
async def get_user(
6568
user_id: str,
69+
request: Request,
70+
db: Session = Depends(get_db),
6671
user_service: UserService = Depends(get_user_service),
67-
authorized: bool = has_roles([UserRole.ADMIN]),
6872
):
6973
try:
74+
# Get current user's auth_id from request state
75+
current_user_auth_id = request.state.user_id
76+
current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first()
77+
78+
if not current_user:
79+
raise HTTPException(status_code=401, detail="User not found")
80+
81+
# Check if user is admin or accessing their own profile
82+
is_admin = current_user.role_id == 3 # Admin role_id
83+
is_self = str(current_user.id) == str(user_id)
84+
85+
if not (is_admin or is_self):
86+
raise HTTPException(status_code=403, detail="You can only access your own profile")
87+
7088
return await user_service.get_user_by_id(user_id)
7189
except HTTPException as http_ex:
7290
raise http_ex
@@ -122,14 +140,34 @@ async def delete_user(
122140
raise HTTPException(status_code=500, detail=str(e))
123141

124142

125-
# soft delete user
143+
# soft delete user (admin or self)
126144
@router.post("/{user_id}/deactivate")
127145
async def deactivate_user(
128146
user_id: str,
147+
request: Request,
148+
db: Session = Depends(get_db),
129149
user_service: UserService = Depends(get_user_service),
130-
authorized: bool = has_roles([UserRole.ADMIN]),
131150
):
132151
try:
152+
# Get current user's auth_id from request state
153+
current_user_auth_id = request.state.user_id
154+
current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first()
155+
156+
if not current_user:
157+
raise HTTPException(status_code=401, detail="User not found")
158+
159+
# Get target user
160+
target_user = db.query(User).filter(User.id == user_id).first()
161+
if not target_user:
162+
raise HTTPException(status_code=404, detail="Target user not found")
163+
164+
# Check if user is admin or modifying themselves
165+
is_admin = current_user.role_id == 3 # Admin role_id
166+
is_self = str(current_user.id) == str(user_id)
167+
168+
if not (is_admin or is_self):
169+
raise HTTPException(status_code=403, detail="You can only deactivate your own account")
170+
133171
await user_service.soft_delete_user_by_id(user_id)
134172
return {"message": "User deactivated successfully"}
135173
except HTTPException as http_ex:
@@ -138,14 +176,34 @@ async def deactivate_user(
138176
raise HTTPException(status_code=500, detail=str(e))
139177

140178

141-
# reactivate user
179+
# reactivate user (admin or self)
142180
@router.post("/{user_id}/reactivate")
143181
async def reactivate_user(
144182
user_id: str,
183+
request: Request,
184+
db: Session = Depends(get_db),
145185
user_service: UserService = Depends(get_user_service),
146-
authorized: bool = has_roles([UserRole.ADMIN]),
147186
):
148187
try:
188+
# Get current user's auth_id from request state
189+
current_user_auth_id = request.state.user_id
190+
current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first()
191+
192+
if not current_user:
193+
raise HTTPException(status_code=401, detail="User not found")
194+
195+
# Get target user
196+
target_user = db.query(User).filter(User.id == user_id).first()
197+
if not target_user:
198+
raise HTTPException(status_code=404, detail="Target user not found")
199+
200+
# Check if user is admin or modifying themselves
201+
is_admin = current_user.role_id == 3 # Admin role_id
202+
is_self = str(current_user.id) == str(user_id)
203+
204+
if not (is_admin or is_self):
205+
raise HTTPException(status_code=403, detail="You can only reactivate your own account")
206+
149207
await user_service.reactivate_user_by_id(user_id)
150208
return {"message": "User reactivated successfully"}
151209
except HTTPException as http_ex:

backend/app/schemas/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ class UserCreateResponse(BaseModel):
127127
role_id: int
128128
auth_id: str
129129
approved: bool
130+
active: bool
130131
form_status: FormStatus
131132
language: Language
132133

backend/app/services/implementations/form_processor.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,25 @@
55
- intake: IntakeFormProcessor (creates UserData, updates form_status)
66
- ranking: RankingProcessor (creates RankingPreference records)
77
- secondary: VolunteerDataProcessor (creates VolunteerData)
8+
- role changes: handles become_participant / become_volunteer transitions
89
"""
910

1011
import logging
12+
from typing import Optional
1113

14+
from sqlalchemy import delete, or_
1215
from sqlalchemy.orm import Session
1316

14-
from app.models import FormSubmission, User
15-
from app.models.RankingPreference import RankingPreference
17+
from app.models import (
18+
AvailabilityTemplate,
19+
FormSubmission,
20+
Match,
21+
RankingPreference,
22+
Role,
23+
Task,
24+
User,
25+
suggested_times,
26+
)
1627
from app.models.User import FormStatus
1728
from app.services.implementations.intake_form_processor import IntakeFormProcessor
1829
from app.services.implementations.volunteer_data_service import VolunteerDataService
@@ -53,7 +64,6 @@ def process_approved_submission(self, submission: FormSubmission) -> None:
5364
elif form_type == "secondary":
5465
self._process_secondary_form(submission, user)
5566
elif form_type in ("become_participant", "become_volunteer"):
56-
# These forms may have different processing logic
5767
self._process_role_change_form(submission, user, form_type)
5868
else:
5969
raise ValueError(f"Unknown form type: {form_type}")
@@ -141,13 +151,101 @@ def _process_role_change_form(self, submission: FormSubmission, user: User, form
141151
Process role change forms (become_participant, become_volunteer).
142152
These may require different handling based on business rules.
143153
"""
144-
# For now, just log that the form was approved
145-
# Actual role changes might need additional business logic
154+
if form_type == "become_participant":
155+
self._process_become_participant_form(submission, user)
156+
return
157+
158+
if form_type == "become_volunteer":
159+
self._process_become_volunteer_form(submission, user)
160+
return
161+
146162
self.logger.info(
147-
f"Role change form ({form_type}) approved for user {user.id}. " "Additional processing may be needed."
163+
"Role change form (%s) approved for user %s. No custom handler defined.",
164+
form_type,
165+
user.id,
148166
)
149167

150-
# Mark user as having a pending role change request if needed
151-
if form_type == "become_volunteer":
152-
user.pending_volunteer_request = True
153-
# Additional logic for become_participant can be added here
168+
def _process_become_participant_form(self, submission: FormSubmission, user: User) -> None:
169+
"""
170+
Convert an existing volunteer into a participant by wiping volunteer data,
171+
removing historical submissions/matches, and re-processing the submission
172+
through the standard intake pipeline.
173+
"""
174+
self.logger.info("Converting volunteer %s into participant via role change form", user.id)
175+
self._cleanup_user_state_for_role_change(user, keep_submission_id=submission.id)
176+
177+
participant_role = self._get_role_by_name("participant")
178+
user.role_id = participant_role.id
179+
user.role = participant_role
180+
user.pending_volunteer_request = False
181+
182+
# Reuse the standard intake processor to populate UserData
183+
intake_processor = IntakeFormProcessor(self.db)
184+
intake_processor.process_form_submission(user_id=str(user.id), form_data=submission.answers or {})
185+
186+
user.form_status = FormStatus.RANKING_TODO
187+
188+
def _process_become_volunteer_form(self, submission: FormSubmission, user: User) -> None:
189+
"""
190+
Convert an existing participant into a volunteer and mark them ready for the secondary app.
191+
"""
192+
self.logger.info("Converting participant %s into volunteer via role change form", user.id)
193+
self._cleanup_user_state_for_role_change(user, keep_submission_id=submission.id)
194+
195+
volunteer_role = self._get_role_by_name("volunteer")
196+
user.role_id = volunteer_role.id
197+
user.role = volunteer_role
198+
user.pending_volunteer_request = False
199+
200+
intake_processor = IntakeFormProcessor(self.db)
201+
intake_processor.process_form_submission(user_id=str(user.id), form_data=submission.answers or {})
202+
203+
user.form_status = FormStatus.SECONDARY_APPLICATION_TODO
204+
205+
def _cleanup_user_state_for_role_change(self, user: User, keep_submission_id) -> None:
206+
"""Remove volunteer-specific records so the user can restart as a participant."""
207+
# Clear ranking preferences
208+
self.db.query(RankingPreference).filter(RankingPreference.user_id == user.id).delete(synchronize_session=False)
209+
210+
# Delete matches and their suggested time associations
211+
matches = self.db.query(Match).filter(or_(Match.participant_id == user.id, Match.volunteer_id == user.id)).all()
212+
if matches:
213+
match_ids = [match.id for match in matches]
214+
self.db.execute(delete(suggested_times).where(suggested_times.c.match_id.in_(match_ids)))
215+
self.db.flush()
216+
for match in matches:
217+
self.db.delete(match)
218+
219+
# Remove tasks referencing the user (participant or assignee)
220+
self.db.query(Task).filter(or_(Task.participant_id == user.id, Task.assignee_id == user.id)).delete(
221+
synchronize_session=False
222+
)
223+
224+
# Remove availability + volunteer-only data
225+
self.db.query(AvailabilityTemplate).filter(AvailabilityTemplate.user_id == user.id).delete(
226+
synchronize_session=False
227+
)
228+
if user.volunteer_data:
229+
self.db.delete(user.volunteer_data)
230+
231+
# Remove any prior intake data so we can rebuild it
232+
if user.user_data:
233+
user.user_data.treatments.clear()
234+
user.user_data.experiences.clear()
235+
user.user_data.loved_one_treatments.clear()
236+
user.user_data.loved_one_experiences.clear()
237+
self.db.delete(user.user_data)
238+
239+
# Delete all historical form submissions except the current role-change request
240+
self.db.query(FormSubmission).filter(
241+
FormSubmission.user_id == user.id, FormSubmission.id != keep_submission_id
242+
).delete(synchronize_session=False)
243+
244+
self.db.flush()
245+
246+
def _get_role_by_name(self, role_name: str) -> Role:
247+
"""Lookup helper to avoid hard-coding role IDs."""
248+
role: Optional[Role] = self.db.query(Role).filter(Role.name == role_name).first()
249+
if not role:
250+
raise ValueError(f"Role '{role_name}' not found in database")
251+
return role

frontend/src/APIClients/intakeAPIClient.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { IntakeFormType } from '@/constants/form';
12
import baseAPIClient from './baseAPIClient';
23

34
export type FormSubmissionStatus = 'pending_approval' | 'approved' | 'rejected';
@@ -49,7 +50,7 @@ class IntakeAPIClient {
4950
*/
5051
async createFormSubmission(submission: {
5152
formId?: string;
52-
formType?: 'participant' | 'volunteer';
53+
formType?: IntakeFormType;
5354
userId?: string;
5455
answers: Record<string, unknown>;
5556
}): Promise<FormSubmission> {

0 commit comments

Comments
 (0)