Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions backend/app/routes/intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from app.schemas.user import UserRole
from app.services.implementations.form_processor import FormProcessor
from app.utilities.db_utils import get_db
from app.utilities.ses_email_service import SESEmailService

# ===== Schemas =====

Expand Down Expand Up @@ -269,6 +270,21 @@ async def create_form_submission(
db.commit()
db.refresh(db_submission)

# Send intake form confirmation email for intake forms
if form and form.type == "intake":
try:
ses_service = SESEmailService()
# Get language (enum values are already "en" or "fr")
language = target_user.language.value if target_user.language else "en"

first_name = target_user.first_name if target_user.first_name else None
ses_service.send_intake_form_confirmation_email(
to_email=target_user.email, first_name=first_name, language=language
)
except Exception as e:
# Log error but don't fail the request
print(f"Failed to send intake form confirmation email: {str(e)}")

# Build response dict
response_dict = {
"id": db_submission.id,
Expand Down
32 changes: 30 additions & 2 deletions backend/app/services/implementations/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,34 @@ def renew_token(self, refresh_token: str) -> Token:

def reset_password(self, email: str) -> None:
try:
# Get user's first name and language if available
first_name = None
language = "en" # Default to English
try:
# Try database first, then fall back to Firebase
try:
user = self.user_service.get_user_by_email(email)
if user:
if user.first_name and user.first_name.strip():
first_name = user.first_name.strip()
# Get language from user (enum values are already "en" or "fr")
if user.language:
language = user.language.value
except Exception:
pass

# Fall back to Firebase if database didn't have first_name
if not first_name:
try:
firebase_user = firebase_admin.auth.get_user_by_email(email)
if firebase_user and firebase_user.display_name:
display_name = firebase_user.display_name.strip()
first_name = display_name.split()[0] if display_name else None
except Exception:
pass
except Exception:
pass

# Use Firebase Admin SDK to generate password reset link
action_code_settings = firebase_admin.auth.ActionCodeSettings(
url="http://localhost:3000/set-new-password",
Expand All @@ -57,8 +85,8 @@ def reset_password(self, email: str) -> None:

reset_link = firebase_admin.auth.generate_password_reset_link(email, action_code_settings)

# Send via SES
email_sent = self.ses_email_service.send_password_reset_email(email, reset_link)
# Send via SES with language
email_sent = self.ses_email_service.send_password_reset_email(email, reset_link, first_name, language)

if email_sent:
self.logger.info(f"Password reset email sent successfully to {email}")
Expand Down
157 changes: 157 additions & 0 deletions backend/app/services/implementations/match_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from app.schemas.time_block import TimeBlockEntity, TimeRange
from app.schemas.user import UserRole
from app.utilities.ses_email_service import SESEmailService
from app.utilities.timezone_utils import get_timezone_from_abbreviation

SCHEDULE_CLEANUP_STATUSES = {
Expand Down Expand Up @@ -95,6 +96,28 @@ async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse:
for match in created_matches:
self.db.refresh(match)

# Send "matches available" email to each volunteer
ses_service = SESEmailService()
for match in created_matches:
try:
volunteer = self.db.get(User, match.volunteer_id)
if volunteer and volunteer.email:
# Get volunteer's language (enum values are already "en" or "fr")
language = volunteer.language.value if volunteer.language else "en"

first_name = volunteer.first_name if volunteer.first_name else None
matches_url = "http://localhost:3000/volunteer/dashboard"

Comment on lines +108 to +110

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Externalize dashboard URLs in match notifications

The match-creation notification email embeds a hardcoded http://localhost:3000/volunteer/dashboard link (and the later scheduled-call/match-acceptance emails in this file use the same localhost base). In any deployed environment these emails will direct volunteers/participants to a non-existent local server, so recipients cannot navigate from the email. Pull the frontend base URL from configuration instead of baking in localhost.

Useful? React with 👍 / 👎.

ses_service.send_matches_available_email(
to_email=volunteer.email,
first_name=first_name,
matches_url=matches_url,
language=language,
)
except Exception as e:
# Log error but don't fail the match creation
self.logger.error(f"Failed to send matches available email to volunteer {match.volunteer_id}: {e}")

responses = [self._build_match_response(match) for match in created_matches]
return MatchCreateResponse(matches=responses)

Expand Down Expand Up @@ -258,6 +281,90 @@ async def schedule_match(
self.db.commit()
self.db.refresh(match)

# Send "call scheduled" email to both participant and volunteer
try:
# Load participant and volunteer with their data
participant = (
self.db.query(User)
.options(joinedload(User.user_data))
.filter(User.id == match.participant_id)
.first()
)
volunteer = (
self.db.query(User)
.options(joinedload(User.user_data))
.filter(User.id == match.volunteer_id)
.first()
)

if participant and volunteer and match.confirmed_time:
ses_service = SESEmailService()
confirmed_time_utc = match.confirmed_time.start_time

# Get participant's timezone and language
participant_tz = ZoneInfo("America/Toronto") # Default to EST
if participant.user_data and participant.user_data.timezone:
tz_result = get_timezone_from_abbreviation(participant.user_data.timezone)
if tz_result:
participant_tz = tz_result

participant_language = participant.language.value if participant.language else "en"

# Get volunteer's timezone and language
volunteer_tz = ZoneInfo("America/Toronto") # Default to EST
if volunteer.user_data and volunteer.user_data.timezone:
tz_result = get_timezone_from_abbreviation(volunteer.user_data.timezone)
if tz_result:
volunteer_tz = tz_result

volunteer_language = volunteer.language.value if volunteer.language else "en"

# Convert time to participant's timezone
participant_time = confirmed_time_utc.astimezone(participant_tz)
participant_date = participant_time.strftime("%B %d, %Y")
participant_time_str = participant_time.strftime("%I:%M %p")
participant_tz_abbr = participant_time.strftime("%Z")

# Convert time to volunteer's timezone
volunteer_time = confirmed_time_utc.astimezone(volunteer_tz)
volunteer_date = volunteer_time.strftime("%B %d, %Y")
volunteer_time_str = volunteer_time.strftime("%I:%M %p")
volunteer_tz_abbr = volunteer_time.strftime("%Z")

# Send to participant
if participant.email:
ses_service.send_call_scheduled_email(
to_email=participant.email,
match_name=f"{volunteer.first_name} {volunteer.last_name}"
if volunteer.first_name and volunteer.last_name
else "Your volunteer",
date=participant_date,
time=participant_time_str,
timezone=participant_tz_abbr,
first_name=participant.first_name,
scheduled_calls_url="http://localhost:3000/participant/dashboard",
language=participant_language,
)

# Send to volunteer
if volunteer.email:
ses_service.send_call_scheduled_email(
to_email=volunteer.email,
match_name=f"{participant.first_name} {participant.last_name}"
if participant.first_name and participant.last_name
else "Your participant",
date=volunteer_date,
time=volunteer_time_str,
timezone=volunteer_tz_abbr,
first_name=volunteer.first_name,
scheduled_calls_url="http://localhost:3000/volunteer/dashboard",
language=volunteer_language,
)

except Exception as e:
# Log error but don't fail the scheduling
self.logger.error(f"Failed to send call scheduled emails for match {match_id}: {e}")

return self._build_match_detail(match)

except HTTPException:
Expand Down Expand Up @@ -317,6 +424,35 @@ async def request_new_times(
self.db.commit()
self.db.refresh(match)

# Send "participant requested new times" email to volunteer
try:
# Load participant and volunteer
participant = self.db.get(User, match.participant_id)
volunteer = self.db.get(User, match.volunteer_id)

if participant and volunteer and volunteer.email:
# Get volunteer's language
volunteer_language = volunteer.language.value if volunteer.language else "en"

# Get participant's name for email
participant_name = (
f"{participant.first_name} {participant.last_name}"
if participant.first_name and participant.last_name
else "A participant"
)

ses_service = SESEmailService()
ses_service.send_participant_requested_new_times_email(
to_email=volunteer.email,
participant_name=participant_name,
first_name=volunteer.first_name,
matches_url="http://localhost:3000/volunteer/dashboard",
language=volunteer_language,
)
except Exception as e:
# Log error but don't fail the request
self.logger.error(f"Failed to send participant requested new times email for match {match_id}: {e}")

return self._build_match_detail(match)

except HTTPException:
Expand Down Expand Up @@ -530,6 +666,27 @@ async def volunteer_accept_match(
self.db.commit()
self.db.refresh(match)

# Send "matches available" email to participant
try:
participant = match.participant
if participant and participant.email:
# Get participant's language (enum values are already "en" or "fr")
language = participant.language.value if participant.language else "en"

first_name = participant.first_name if participant.first_name else None
matches_url = "http://localhost:3000/participant/dashboard"

ses_service = SESEmailService()
ses_service.send_matches_available_email(
to_email=participant.email,
first_name=first_name,
matches_url=matches_url,
language=language,
)
except Exception as e:
# Log error but don't fail the match acceptance
self.logger.error(f"Failed to send matches available email to participant {match.participant_id}: {e}")

# Return match detail for participant view (includes suggested times)
return self._build_match_detail(match)
except HTTPException:
Expand Down
Empty file.
Empty file.
90 changes: 78 additions & 12 deletions backend/app/utilities/ses/ses_templates.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,92 @@
[
{
"HtmlPart": "app/utilities/ses/template_files/test.html",
"HtmlPart": "app/utilities/ses/template_files/compiled/test.html",
"SubjectPart": "Testing Email SES Template",
"TemplateName": "Test",
"TextPart": "app/utilities/ses/template_files/test.txt"
"TextPart": "app/utilities/ses/template_files/text/test.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/email_verification_en.html",
"SubjectPart": "Verify Your Email Address",
"HtmlPart": "app/utilities/ses/template_files/compiled/email_verification_en.html",
"SubjectPart": "{{first_name}}, confirm your email - First Connection Peer Support Program",
"TemplateName": "EmailVerificationEn",
"TextPart": "app/utilities/ses/template_files/email_verification_en.txt"
"TextPart": "app/utilities/ses/template_files/text/email_verification_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/email_verification_fr.html",
"SubjectPart": "Vérifiez votre adresse courriel",
"HtmlPart": "app/utilities/ses/template_files/compiled/email_verification_fr.html",
"SubjectPart": "{{first_name}}, confirmation de l'adresse courriel – Programme de soutien par les pairs Premier contact",
"TemplateName": "EmailVerificationFr",
"TextPart": "app/utilities/ses/template_files/email_verification_fr.txt"
"TextPart": "app/utilities/ses/template_files/text/email_verification_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/password_reset.html",
"SubjectPart": "Reset Your Password",
"TemplateName": "PasswordReset",
"TextPart": "app/utilities/ses/template_files/password_reset.txt"
"HtmlPart": "app/utilities/ses/template_files/compiled/password_reset_en.html",
"SubjectPart": "Reset Your Password - First Connection Peer Support Program",
"TemplateName": "PasswordResetEn",
"TextPart": "app/utilities/ses/template_files/text/password_reset_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/password_reset_fr.html",
"SubjectPart": "Réinitialisation du mot de passe – Programme de soutien par les pairs Premier contact",
"TemplateName": "PasswordResetFr",
"TextPart": "app/utilities/ses/template_files/text/password_reset_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/intake_form_confirmation_en.html",
"SubjectPart": "We received your intake form - First Connection Peer Support Program",
"TemplateName": "IntakeFormConfirmationEn",
"TextPart": "app/utilities/ses/template_files/text/intake_form_confirmation_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/intake_form_confirmation_fr.html",
"SubjectPart": "Réception de votre formulaire de demande – Programme de soutien par les pairs Premier contact",
"TemplateName": "IntakeFormConfirmationFr",
"TextPart": "app/utilities/ses/template_files/text/intake_form_confirmation_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/matches_available_en.html",
"SubjectPart": "{{first_name}}, you have new matches - First Connection Peer Support Program",
"TemplateName": "MatchesAvailableEn",
"TextPart": "app/utilities/ses/template_files/text/matches_available_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/matches_available_fr.html",
"SubjectPart": "{{first_name}}, nouveaux jumelages – Programme de soutien par les pairs Premier contact",
"TemplateName": "MatchesAvailableFr",
"TextPart": "app/utilities/ses/template_files/text/matches_available_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/call_scheduled_en.html",
"SubjectPart": "Call confirmed with {{match_name}} @ {{date}} {{time}} {{timezone}} - First Connection Peer Support Program",
"TemplateName": "CallScheduledEn",
"TextPart": "app/utilities/ses/template_files/text/call_scheduled_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/call_scheduled_fr.html",
"SubjectPart": "Confirmation de l'appel avec {{match_name}} le {{date}} à {{time}} {{timezone}} – Programme de soutien par les pairs Premier contact",
"TemplateName": "CallScheduledFr",
"TextPart": "app/utilities/ses/template_files/text/call_scheduled_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/participant_requested_new_times_en.html",
"SubjectPart": "{{participant_name}} requested new times - First Connection Peer Support Program",
"TemplateName": "ParticipantRequestedNewTimesEn",
"TextPart": "app/utilities/ses/template_files/text/participant_requested_new_times_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/participant_requested_new_times_fr.html",
"SubjectPart": "Demande d'une autre plage horaire par {{participant_name}} – Programme de soutien par les pairs Premier contact",
"TemplateName": "ParticipantRequestedNewTimesFr",
"TextPart": "app/utilities/ses/template_files/text/participant_requested_new_times_fr.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/volunteer_accepted_new_times_en.html",
"SubjectPart": "{{volunteer_name}} confirmed your new time @ {{date}} {{time}} {{timezone}} - First Connection Peer Support Program",
"TemplateName": "VolunteerAcceptedNewTimesEn",
"TextPart": "app/utilities/ses/template_files/text/volunteer_accepted_new_times_en.txt"
},
{
"HtmlPart": "app/utilities/ses/template_files/compiled/volunteer_accepted_new_times_fr.html",
"SubjectPart": "Confirmation de la nouvelle plage horaire, le {{date}} à {{time}} {{timezone}}, par {{volunteer_name}} – Programme de soutien par les pairs Premier contact",
"TemplateName": "VolunteerAcceptedNewTimesFr",
"TextPart": "app/utilities/ses/template_files/text/volunteer_accepted_new_times_fr.txt"
}
]
Loading