diff --git a/.env.sample b/.env.sample index 3bc960bc..2b3bb30c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,8 +1,17 @@ -POSTGRES_DATABASE= -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DATABASE_URL= -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_REGION= -SES_EMAIL_FROM= +ENV=development # set ENV=production in prod + +POSTGRES_DATABASE=llsc +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/llsc +POSTGRES_TEST_DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/llsc_test + +FIREBASE_WEB_API_KEY= + +AWS_ACCESS_KEY= +AWS_SECRET_KEY= +AWS_REGION=ca-central-1 +SES_SOURCE_EMAIL= +SES_SOURCE_EMAIL_FR= + +FRONTEND_URL=http://localhost:3000 diff --git a/backend/.env.sample b/backend/.env.sample new file mode 100644 index 00000000..23648fab --- /dev/null +++ b/backend/.env.sample @@ -0,0 +1,17 @@ +ENV=development # set ENV=production in prod + +POSTGRES_DATABASE=llsc +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/llsc +POSTGRES_TEST_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/llsc_test + +FIREBASE_WEB_API_KEY= + +AWS_ACCESS_KEY= +AWS_SECRET_KEY= +AWS_REGION=ca-central-1 +SES_SOURCE_EMAIL= +SES_SOURCE_EMAIL_FR= + +FRONTEND_URL=http://localhost:3000 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 24a59248..5d97dada 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -33,7 +33,7 @@ def create_app(): app.add_middleware( CORSMiddleware, allow_origins=[ - "http://localhost:3000", + os.getenv("FRONTEND_URL", "http://localhost:3000"), "https://uw-blueprint-starter-code.firebaseapp.com", "https://uw-blueprint-starter-code.web.app", # TODO: create a separate middleware function to dynamically diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 02cdf6be..29839652 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -19,15 +19,21 @@ # TODO: ADD RATE LIMITING @router.post("/register", response_model=UserCreateResponse) async def register_user(user: UserCreateRequest, user_service: UserService = Depends(get_user_service)): - allowed_Admins = [ + allowed_admins = { "umair.hkar@gmail.com", "umairmhundekar@gmail.com", "yash@kotharigroup.com", "evan.beiduo.wu@gmail.com", "richardbai@uwblueprint.org", - ] + "brooke.dewhurst@lls.org", + "jennifer.heroux-bourduas@lls.org", + "caroline.mitchell@lls.org", + "jolyane.pelletier@lls.org", + "megan.norrish@lls.org", + } if user.role == UserRole.ADMIN: - if user.email not in allowed_Admins: + normalized_email = user.email.lower() if user.email else "" + if normalized_email not in allowed_admins: raise HTTPException(status_code=403, detail="Access denied. Admin privileges required for admin portal") try: diff --git a/backend/app/seeds/runner.py b/backend/app/seeds/runner.py index c880e577..c4f2e5ec 100644 --- a/backend/app/seeds/runner.py +++ b/backend/app/seeds/runner.py @@ -45,24 +45,40 @@ def seed_database(verbose: bool = True) -> None: Args: verbose: Whether to print detailed output """ + # Check environment to determine if we should seed test data + env = os.getenv("ENV", "development").lower() + is_production = env == "production" + if verbose: print("🌱 Starting database seeding...") + if is_production: + print("āš ļø Production mode: Skipping test data (Users, Ranking Preferences)") + else: + print(f"šŸ”§ {env.capitalize()} mode: Including test data") session = get_database_session() try: # Run all seed functions in dependency order + # Reference data - always seed these seed_functions = [ ("Roles", seed_roles), ("Treatments", seed_treatments), ("Experiences", seed_experiences), ("Qualities", seed_qualities), ("Forms", seed_forms), - ("Users", seed_users), - ("Ranking Preferences", seed_ranking_preferences), ("Match Status", seed_match_status), ] + # Test data - only seed in non-production environments + if not is_production: + seed_functions.extend( + [ + ("Users", seed_users), + ("Ranking Preferences", seed_ranking_preferences), + ] + ) + for name, seed_func in seed_functions: if verbose: print(f"\nšŸ“¦ Seeding {name}...") diff --git a/backend/app/server.py b/backend/app/server.py index e38e4f99..d491bf2d 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -1,4 +1,5 @@ import logging +import os from contextlib import asynccontextmanager from typing import Union @@ -87,7 +88,7 @@ async def lifespan(_: FastAPI): # Shutdown scheduler gracefully log.info("Shutting down scheduler...") - scheduler.shutdown() + scheduler.shutdown(wait=False) # Don't wait for running jobs to prevent interpreter shutdown race condition # Dispose database engine to close all connection pools # This prevents async generator cleanup errors during shutdown @@ -104,8 +105,7 @@ async def lifespan(_: FastAPI): app.add_middleware( CORSMiddleware, allow_origins=[ - "http://localhost:3000", - "http://localhost:3002", + os.getenv("FRONTEND_URL", "http://localhost:3000"), "https://uw-blueprint-starter-code.firebaseapp.com", "https://uw-blueprint-starter-code.web.app", # TODO: create a separate middleware function to dynamically diff --git a/backend/app/services/implementations/auth_service.py b/backend/app/services/implementations/auth_service.py index 809483a8..3a5168e5 100644 --- a/backend/app/services/implementations/auth_service.py +++ b/backend/app/services/implementations/auth_service.py @@ -79,7 +79,7 @@ def reset_password(self, email: str) -> None: # Use Firebase Admin SDK to generate password reset link action_code_settings = firebase_admin.auth.ActionCodeSettings( - url="http://localhost:3000/set-new-password", + url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/set-new-password", handle_code_in_app=True, ) @@ -152,7 +152,7 @@ def send_email_verification_link(self, email: str, language: str = None) -> None # Use Firebase Admin SDK to generate email verification link action_code_settings = firebase_admin.auth.ActionCodeSettings( - url="http://localhost:3000/action", # URL to redirect after verification + url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/action", # URL to redirect after verification handle_code_in_app=True, ) diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index 678abb84..a09ad607 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -1,4 +1,5 @@ import logging +import os from datetime import date, datetime, timedelta, timezone from typing import List, Optional from uuid import UUID @@ -106,7 +107,7 @@ async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse: 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" + matches_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard" ses_service.send_matches_available_email( to_email=volunteer.email, @@ -342,7 +343,7 @@ async def schedule_match( time=participant_time_str, timezone=participant_tz_abbr, first_name=participant.first_name, - scheduled_calls_url="http://localhost:3000/participant/dashboard", + scheduled_calls_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard", language=participant_language, ) @@ -357,7 +358,7 @@ async def schedule_match( time=volunteer_time_str, timezone=volunteer_tz_abbr, first_name=volunteer.first_name, - scheduled_calls_url="http://localhost:3000/volunteer/dashboard", + scheduled_calls_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard", language=volunteer_language, ) @@ -446,7 +447,7 @@ async def request_new_times( to_email=volunteer.email, participant_name=participant_name, first_name=volunteer.first_name, - matches_url="http://localhost:3000/volunteer/dashboard", + matches_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard", language=volunteer_language, ) except Exception as e: @@ -532,7 +533,7 @@ async def cancel_match_by_participant( time=volunteer_time_str, timezone=volunteer_tz_abbr, first_name=volunteer.first_name, - dashboard_url="http://localhost:3000/volunteer/dashboard", + dashboard_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard", language=volunteer_language, ) except Exception as e: @@ -623,7 +624,7 @@ async def cancel_match_by_volunteer( time=participant_time_str, timezone=participant_tz_abbr, first_name=participant.first_name, - request_matches_url="http://localhost:3000/participant/dashboard", + request_matches_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard", language=participant_language, ) except Exception as e: @@ -829,7 +830,7 @@ async def volunteer_accept_match( 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" + matches_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard" ses_service = SESEmailService() ses_service.send_matches_available_email( diff --git a/backend/app/utilities/ses_email_service.py b/backend/app/utilities/ses_email_service.py index c35bead8..61c87ab0 100644 --- a/backend/app/utilities/ses_email_service.py +++ b/backend/app/utilities/ses_email_service.py @@ -217,7 +217,7 @@ def send_matches_available_email( # Default to dashboard if no specific URL provided if not matches_url: - matches_url = "http://localhost:3000/participant/dashboard" + matches_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard" template_data = {"first_name": first_name if first_name else "there", "matches_url": matches_url} @@ -260,7 +260,7 @@ def send_call_scheduled_email( # Default to dashboard if no specific URL provided if not scheduled_calls_url: - scheduled_calls_url = "http://localhost:3000/participant/dashboard" + scheduled_calls_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard" template_data = { "first_name": first_name if first_name else "there", @@ -304,7 +304,7 @@ def send_participant_requested_new_times_email( # Default to dashboard if no specific URL provided if not matches_url: - matches_url = "http://localhost:3000/volunteer/dashboard" + matches_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard" template_data = { "first_name": first_name if first_name else "there", @@ -351,7 +351,7 @@ def send_volunteer_accepted_new_times_email( # Default to dashboard if no specific URL provided if not scheduled_calls_url: - scheduled_calls_url = "http://localhost:3000/participant/dashboard" + scheduled_calls_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard" template_data = { "first_name": first_name if first_name else "there", @@ -401,7 +401,7 @@ def send_participant_cancelled_email( # Default to dashboard if no specific URL provided if not dashboard_url: - dashboard_url = "http://localhost:3000/volunteer/dashboard" + dashboard_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/volunteer/dashboard" template_data = { "first_name": first_name if first_name else "there", @@ -451,7 +451,7 @@ def send_volunteer_cancelled_email( # Default to dashboard if no specific URL provided if not request_matches_url: - request_matches_url = "http://localhost:3000/participant/dashboard" + request_matches_url = f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/participant/dashboard" template_data = { "first_name": first_name if first_name else "there", diff --git a/frontend/.env.sample b/frontend/.env.sample new file mode 100644 index 00000000..74a1f41a --- /dev/null +++ b/frontend/.env.sample @@ -0,0 +1,11 @@ +POSTGRES_DATABASE=llsc +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/llsc + +NEXT_PUBLIC_FIREBASE_WEB_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= + +REACT_APP_BACKEND_URL=http://localhost:8080 diff --git a/frontend/src/hooks/useEmailVerification.ts b/frontend/src/hooks/useEmailVerification.ts index 84e1393f..dbecfb31 100644 --- a/frontend/src/hooks/useEmailVerification.ts +++ b/frontend/src/hooks/useEmailVerification.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; import baseAPIClient from '@/APIClients/baseAPIClient'; +import { detectUserLanguage } from '@/utils/languageDetection'; export const useEmailVerification = () => { const [isLoading, setIsLoading] = useState(false); @@ -12,26 +13,8 @@ export const useEmailVerification = () => { setSuccess(false); try { - // Get browser language from navigator - check all languages in preference order - let detectedLang = 'en'; - - // Check navigator.languages array first (user's preferred languages in order) - if (navigator.languages && navigator.languages.length > 0) { - for (const lang of navigator.languages) { - const langCode = lang.split('-')[0].toLowerCase(); - if (langCode === 'fr') { - detectedLang = 'fr'; - break; - } else if (langCode === 'en') { - detectedLang = 'en'; - // Continue checking in case French comes later - } - } - } else if (navigator.language) { - // Fallback to navigator.language - const langCode = navigator.language.split('-')[0].toLowerCase(); - detectedLang = langCode === 'fr' ? 'fr' : 'en'; - } + // Detect user's preferred language from browser settings + const detectedLang = detectUserLanguage(); await baseAPIClient.post( `/auth/send-email-verification/${encodeURIComponent(email)}?language=${detectedLang}`, diff --git a/frontend/src/pages/welcome.tsx b/frontend/src/pages/welcome.tsx index 2ee8a350..a5794bbf 100644 --- a/frontend/src/pages/welcome.tsx +++ b/frontend/src/pages/welcome.tsx @@ -7,11 +7,18 @@ import { AuthenticatedUser, FormStatus, UserRole } from '@/types/authTypes'; import { roleIdToUserRole } from '@/utils/roleUtils'; import { getRedirectRoute } from '@/constants/formStatusRoutes'; import { AuthPageLayout } from '@/components/layout'; +import { detectUserLanguage, getProgramInfoUrl } from '@/utils/languageDetection'; export default function WelcomePage() { const router = useRouter(); const [currentUser, setCurrentUser] = useState(null); const [loading, setLoading] = useState(true); + const [userLanguage, setUserLanguage] = useState<'en' | 'fr'>('en'); + + // Detect user's preferred language on client-side only + useEffect(() => { + setUserLanguage(detectUserLanguage()); + }, []); useEffect(() => { const evaluate = async () => { @@ -107,7 +114,9 @@ export default function WelcomePage() { You can learn more about the program{' '} here diff --git a/frontend/src/utils/languageDetection.ts b/frontend/src/utils/languageDetection.ts new file mode 100644 index 00000000..aa0ae8a8 --- /dev/null +++ b/frontend/src/utils/languageDetection.ts @@ -0,0 +1,47 @@ +/** + * Detects the user's preferred language from browser settings. + * Checks navigator.languages (preferred language list) and respects the order. + * Returns 'fr' if French is the user's preferred language, otherwise defaults to 'en'. + * + * SSR-safe: Returns 'en' if navigator is not available (server-side rendering, tests, etc.) + * + * @returns 'en' or 'fr' + */ +export const detectUserLanguage = (): 'en' | 'fr' => { + // SSR guard - navigator is only available in browser + if (typeof navigator === 'undefined') { + return 'en'; + } + + // Check navigator.languages array first (user's preferred languages in order) + if (navigator.languages && navigator.languages.length > 0) { + for (const lang of navigator.languages) { + const langCode = lang.split('-')[0].toLowerCase(); + // Return first match - this respects user's language preference order + if (langCode === 'fr') { + return 'fr'; + } else if (langCode === 'en') { + return 'en'; + } + } + } else if (navigator.language) { + // Fallback to navigator.language + const langCode = navigator.language.split('-')[0].toLowerCase(); + return langCode === 'fr' ? 'fr' : 'en'; + } + + // Default to English + return 'en'; +}; + +/** + * Returns the appropriate LLSC program information URL based on the user's language. + * + * @param language - The user's preferred language ('en' or 'fr') + * @returns The URL for the First Connection Peer Support Program page + */ +export const getProgramInfoUrl = (language: 'en' | 'fr'): string => { + return language === 'fr' + ? 'https://www.cancersdusang.ca/programme-de-soutien-par-les-pairs-premier-contact' + : 'https://www.bloodcancers.ca/first-connection-peer-support-program'; +};