From 9d228ebde89f55be529101f60e0ca429efb30656 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Wed, 19 Nov 2025 14:17:18 -0500 Subject: [PATCH 1/9] beginning the admin dash profile view, ui is mostly matching figma. todo is to make the edit/delete/deactivate buttons actually do something, add back protectedpage and endpoint auth requirements so only admins can view this, testing for more cases (eg. volunteers without cancer or without loved ones) --- backend/app/routes/user.py | 2 +- backend/app/schemas/user.py | 7 + backend/app/schemas/user_data.py | 70 ++ .../services/implementations/user_service.py | 31 +- frontend/src/APIClients/authAPIClient.ts | 14 +- frontend/src/pages/admin/users/[id].tsx | 723 ++++++++++++++++++ frontend/src/types/userTypes.ts | 78 ++ frontend/src/utils/dateUtils.ts | 10 + 8 files changed, 919 insertions(+), 16 deletions(-) create mode 100644 backend/app/schemas/user_data.py create mode 100644 frontend/src/pages/admin/users/[id].tsx create mode 100644 frontend/src/types/userTypes.ts diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index eec68444..fabdbed4 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -63,7 +63,7 @@ async def get_users( async def get_user( user_id: str, user_service: UserService = Depends(get_user_service), - authorized: bool = has_roles([UserRole.ADMIN]), + # authorized: bool = has_roles([UserRole.ADMIN]), ): try: return await user_service.get_user_by_id(user_id) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index dc084c09..3b095e29 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator +from .time_block import TimeBlockEntity +from .user_data import UserDataResponse +from .volunteer_data import VolunteerDataResponse + # TODO: # confirm complexity rules for fields (such as password) @@ -137,6 +141,9 @@ class UserResponse(BaseModel): approved: bool role: "RoleResponse" form_status: FormStatus + user_data: Optional[UserDataResponse] = None + volunteer_data: Optional[VolunteerDataResponse] = None + availability: List[TimeBlockEntity] = [] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/user_data.py b/backend/app/schemas/user_data.py new file mode 100644 index 00000000..175f7c15 --- /dev/null +++ b/backend/app/schemas/user_data.py @@ -0,0 +1,70 @@ +from datetime import date +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class TreatmentResponse(BaseModel): + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +class ExperienceResponse(BaseModel): + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +class UserDataResponse(BaseModel): + id: UUID + user_id: UUID + + # Personal Information + first_name: Optional[str] + last_name: Optional[str] + date_of_birth: Optional[date] + email: Optional[str] + phone: Optional[str] + city: Optional[str] + province: Optional[str] + postal_code: Optional[str] + + # Demographics + gender_identity: Optional[str] + pronouns: Optional[List[str]] + ethnic_group: Optional[List[str]] + marital_status: Optional[str] + has_kids: Optional[str] + timezone: Optional[str] + + # Cancer Experience + diagnosis: Optional[str] + date_of_diagnosis: Optional[date] + + # Custom entries + other_ethnic_group: Optional[str] + gender_identity_custom: Optional[str] + additional_info: Optional[str] + + # Flow control + has_blood_cancer: Optional[str] + caring_for_someone: Optional[str] + + # Loved One Info + loved_one_gender_identity: Optional[str] + loved_one_age: Optional[str] + loved_one_diagnosis: Optional[str] + loved_one_date_of_diagnosis: Optional[date] + + # Relations + treatments: List[TreatmentResponse] = [] + experiences: List[ExperienceResponse] = [] + loved_one_treatments: List[TreatmentResponse] = [] + loved_one_experiences: List[ExperienceResponse] = [] + + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 995a073a..1ee65da5 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -4,10 +4,10 @@ import firebase_admin.auth from fastapi import HTTPException -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.interfaces.user_service import IUserService -from app.models import FormStatus, Role, User +from app.models import FormStatus, Role, User, UserData, VolunteerData from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -151,7 +151,20 @@ def get_user_by_email(self, email: str): async def get_user_by_id(self, user_id: str) -> UserResponse: try: - user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + user = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.user_data).joinedload(UserData.treatments), + joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(User.user_data).joinedload(UserData.loved_one_treatments), + joinedload(User.user_data).joinedload(UserData.loved_one_experiences), + joinedload(User.volunteer_data), + joinedload(User.availability), + ) + .filter(User.id == UUID(user_id)) + .first() + ) if not user: raise HTTPException(status_code=404, detail="User not found") return UserResponse.model_validate(user) @@ -180,7 +193,17 @@ def get_user_role_by_auth_id(self, auth_id: str) -> str: async def get_users(self) -> List[UserResponse]: try: # Filter users to only include participants and volunteers (role_id 1 and 2) - users = self.db.query(User).join(Role).filter(User.role_id.in_([1, 2])).all() + users = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.user_data), + joinedload(User.volunteer_data), + joinedload(User.availability), + ) + .filter(User.role_id.in_([1, 2])) + .all() + ) return [UserResponse.model_validate(user) for user in users] except Exception as e: self.logger.error(f"Error getting users: {str(e)}") diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index efa0223d..36dfc1f1 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -407,17 +407,9 @@ export const refresh = async (): Promise => { } }; -// User types for admin and user management -export interface UserResponse { - id: string; - firstName: string | null; - lastName: string | null; - email: string; - roleId: number; - authId: string; - approved: boolean; - formStatus: string; -} +import { UserResponse } from '../types/userTypes'; + +export type { UserResponse }; export interface UserListResponse { users: UserResponse[]; diff --git a/frontend/src/pages/admin/users/[id].tsx b/frontend/src/pages/admin/users/[id].tsx new file mode 100644 index 00000000..f7128173 --- /dev/null +++ b/frontend/src/pages/admin/users/[id].tsx @@ -0,0 +1,723 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, + HStack, + Badge, + Spinner, + SimpleGrid, + IconButton, + Grid, + GridItem, + Input, +} from '@chakra-ui/react'; +import { FiEdit2, FiUser, FiFileText, FiUsers, FiHeart } from 'react-icons/fi'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { AdminHeader } from '@/components/admin/AdminHeader'; +import { UserRole } from '@/types/authTypes'; +import { getUserById } from '@/APIClients/authAPIClient'; +import { UserResponse } from '@/types/userTypes'; +import { COLORS } from '@/constants/colors'; +import { roleIdToUserRole } from '@/utils/roleUtils'; +import { formatDateLong } from '@/utils/dateUtils'; + +// Helper to format array of strings (e.g. pronouns) +const formatArray = (arr?: string[] | null) => { + if (!arr || arr.length === 0) return 'N/A'; + return arr.join(', '); +}; + +export default function AdminUserProfile() { + const router = useRouter(); + const { id } = router.query; + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (id) { + const fetchUser = async () => { + try { + const userData = await getUserById(id as string); + setUser(userData); + } catch (error) { + console.error('Failed to fetch user:', error); + } finally { + setLoading(false); + } + }; + fetchUser(); + } + }, [id]); + + if (loading) { + return ( + + + + + + ); + } + + if (!user) { + return ( + + + + User not found + + + ); + } + + const role = roleIdToUserRole(user.roleId); + const userData = user.userData; + const volunteerData = user.volunteerData; + + // Determine active tab based on route or query param + const activeTab = router.query.tab as string || 'profile'; + const isProfileActive = activeTab === 'profile' || !router.query.tab; + const isFormsActive = activeTab === 'forms'; + const isMatchesActive = activeTab === 'matches'; + + return ( + + + + {/* Left Sidebar */} + + + + + + + + + + {/* Profile Summary Card */} + + + Profile Summary + + + + + + + Name + {user.firstName} {user.lastName} + + + Email Address + {user.email} + + + Birthday + {userData?.dateOfBirth ? formatDateLong(userData.dateOfBirth) : 'N/A'} + + + Phone Number + {userData?.phone || 'N/A'} + + + Gender + {userData?.genderIdentity || 'N/A'} + + + Pronouns + {formatArray(userData?.pronouns)} + + + Time Zone + {userData?.timezone || 'N/A'} + + + Location + + {[userData?.city, userData?.province].filter(Boolean).join(', ') || 'N/A'} + + + + + + + {/* Main Content */} + + + {/* Header Section */} + + + + {user.firstName} {user.lastName} + + + {role} + + + + + + + + + {/* Overview - Only for Volunteers */} + {role === UserRole.VOLUNTEER && ( + <> + + + Overview + + + {volunteerData?.experience || userData?.additionalInfo || "No overview provided."} + + + + + + )} + + {/* Detailed Info */} + + {/* User's Own Cancer Experience (only if user has cancer) */} + {userData?.hasBloodCancer === 'yes' && ( + + + Blood cancer experience information + + + + + + Diagnosis + + + + {userData?.diagnosis ? ( + + {userData.diagnosis} + + ) : ( + N/A + )} + + + + + Date of Diagnosis + + + {userData?.dateOfDiagnosis ? formatDateLong(userData.dateOfDiagnosis) : 'N/A'} + + + + + Treatments + + + + {userData?.treatments?.length ? ( + userData.treatments.map(t => {t.name}) + ) : None listed} + + + + + + Experiences + + + + {userData?.experiences?.length ? ( + userData.experiences.map(e => {e.name}) + ) : None listed} + + + + + )} + + {/* Divider between user's own info and loved one info */} + {userData?.hasBloodCancer === 'yes' && userData?.caringForSomeone === 'yes' && ( + + )} + + {/* Loved One Info (only if user is caring for someone) */} + {userData?.caringForSomeone === 'yes' && ( + + {userData?.hasBloodCancer !== 'yes' && ( + + Blood cancer experience information + + )} + {userData?.hasBloodCancer === 'yes' && ( + + Loved One's Blood cancer experience information + + )} + + + + + Loved One's Diagnosis + + + + + + + Loved One's Date of Diagnosis + + + + + + + + + Treatments Loved One Has Done + + + + + {userData?.lovedOneTreatments?.length ? ( + userData.lovedOneTreatments.map(t => {t.name}) + ) : None listed} + + + + + + + + Experiences Loved One Had + + + + + {userData?.lovedOneExperiences?.length ? ( + userData.lovedOneExperiences.map(e => {e.name}) + ) : None listed} + + + + + )} + + {/* Availability - Only for Volunteers */} + {role === UserRole.VOLUNTEER && ( + + + + Availability + + + + + + {/* Grid */} + + + {/* Header Row */} + + EST + + {['Mon', 'Tues', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => ( + + {day} + + ))} + + {/* Time Rows */} + {[ + '8:00 AM', '8:30 AM', '9:00 AM', '9:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM', + '12:00 PM', '12:30 PM', '1:00 PM', '1:30 PM', '2:00 PM', '2:30 PM', '3:00 PM', '3:30 PM', + '4:00 PM', '4:30 PM', '5:00 PM', '5:30 PM', '6:00 PM', '6:30 PM', '7:00 PM', '7:30 PM' + ].map((time, timeIndex) => { + const isHour = timeIndex % 2 === 0; + return ( + + {/* Time Label */} + 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} + borderColor={COLORS.grayBorder} + bg="white" + display="flex" + alignItems="center" + > + {time} + + + {/* Days */} + {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => { + const isAvailable = user.availability?.some(block => { + const date = new Date(block.startTime); + // Adjust for timezone if needed. + // Assuming block.startTime is ISO string and we want to display in local time or specific timezone. + // For now, let's assume the backend returns UTC and we want to display in EST (as per header). + // Ideally we should use a library like date-fns-tz or moment-timezone. + // But for this simplified view, let's just check getDay/getHours/getMinutes. + + const jsDay = date.getDay(); // 0=Sun, 1=Mon... + const gridDay = jsDay === 0 ? 6 : jsDay - 1; + + const hour = date.getHours(); + const minute = date.getMinutes(); + + // Calculate target hour and minute based on timeIndex + // timeIndex 0 -> 8:00, 1 -> 8:30, 2 -> 9:00... + const targetHour = 8 + Math.floor(timeIndex / 2); + const targetMinute = (timeIndex % 2) * 30; + + return gridDay === dayIndex && hour === targetHour && minute === targetMinute; + }); + + return ( + 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} + borderLeft="1px solid" + borderColor={COLORS.grayBorder} + bg={isAvailable ? '#FFF4E6' : 'white'} + h="30px" + /> + ); + })} + + )})} + + + + {/* Summary Sidebar */} + + Your Availability + + {['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, index) => { + // Filter blocks for this day + // Note: getDay() returns 0 for Sunday, 1 for Monday, etc. + // Our map index 0 is Monday, so we need to match correctly. + // Monday (index 0) -> getDay() 1 + // ... + // Saturday (index 5) -> getDay() 6 + // Sunday (index 6) -> getDay() 0 + const targetDay = index === 6 ? 0 : index + 1; + + const dayBlocks = user.availability?.filter(block => { + const date = new Date(block.startTime); + return date.getDay() === targetDay; + }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + + if (!dayBlocks || dayBlocks.length === 0) { + return null; + } + + // Group contiguous blocks into ranges + const ranges: { start: Date; end: Date }[] = []; + if (dayBlocks.length > 0) { + let currentStart = new Date(dayBlocks[0].startTime); + let currentEnd = new Date(dayBlocks[0].startTime); + currentEnd.setMinutes(currentEnd.getMinutes() + 30); // Each block is 30 mins + + for (let i = 1; i < dayBlocks.length; i++) { + const nextBlockStart = new Date(dayBlocks[i].startTime); + if (nextBlockStart.getTime() === currentEnd.getTime()) { + // Contiguous, extend current range + currentEnd.setMinutes(currentEnd.getMinutes() + 30); + } else { + // Gap found, push current range and start new one + ranges.push({ start: currentStart, end: currentEnd }); + currentStart = nextBlockStart; + currentEnd = new Date(nextBlockStart); + currentEnd.setMinutes(currentEnd.getMinutes() + 30); + } + } + ranges.push({ start: currentStart, end: currentEnd }); + } + + return ( + + {day}: + + {ranges.map((range, i) => { + // Format time: 12:00 PM - 4:00 PM + // Using a simple formatter for now + const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + }; + return ( + + {formatTime(range.start)} - {formatTime(range.end)} + + ); + })} + + + ); + })} + + + + + )} + + + + + + ); +} diff --git a/frontend/src/types/userTypes.ts b/frontend/src/types/userTypes.ts new file mode 100644 index 00000000..a7ced891 --- /dev/null +++ b/frontend/src/types/userTypes.ts @@ -0,0 +1,78 @@ +import { UserRole } from './authTypes'; + +export interface Treatment { + id: number; + name: string; +} + +export interface Experience { + id: number; + name: string; +} + +export interface UserData { + id: string; + userId: string; + firstName: string | null; + lastName: string | null; + dateOfBirth: string | null; + email: string | null; + phone: string | null; + city: string | null; + province: string | null; + postalCode: string | null; + genderIdentity: string | null; + pronouns: string[] | null; + ethnicGroup: string[] | null; + maritalStatus: string | null; + hasKids: string | null; + timezone: string | null; + diagnosis: string | null; + dateOfDiagnosis: string | null; + otherEthnicGroup: string | null; + genderIdentityCustom: string | null; + additionalInfo: string | null; + hasBloodCancer: string | null; + caringForSomeone: string | null; + lovedOneGenderIdentity: string | null; + lovedOneAge: string | null; + lovedOneDiagnosis: string | null; + lovedOneDateOfDiagnosis: string | null; + treatments: Treatment[]; + experiences: Experience[]; + lovedOneTreatments: Treatment[]; + lovedOneExperiences: Experience[]; +} + +export interface VolunteerData { + id: string; + userId: string; + experience: string | null; + referencesJson: string | null; + additionalComments: string | null; + submittedAt: string; +} + +export interface TimeBlock { + id: number; + startTime: string; +} + +export interface UserResponse { + id: string; + firstName: string | null; + lastName: string | null; + email: string; + roleId: number; + authId: string; + approved: boolean; + formStatus: string; + role: { + id: number; + name: string; + }; + userData?: UserData | null; + volunteerData?: VolunteerData | null; + availability?: TimeBlock[]; +} + diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index a2a65e59..cbd7120f 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -34,6 +34,16 @@ export function formatDateShort(dateString: string): string { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } +/** + * Format a date to show full date (e.g., "February 26, 2024") + * @param dateString - ISO 8601 datetime string + * @returns Formatted date string + */ +export function formatDateLong(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +} + /** * Format a time to show in 12-hour format (e.g., "12:00PM") * @param dateString - ISO 8601 datetime string From 4955100e7fc84eff6290d929f0e1f72ef04c45a1 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 20 Nov 2025 14:07:01 -0500 Subject: [PATCH 2/9] finished more of the admin dash profile screen. todo: test alllayouts, wire up delete and deactivate buttons, edit functionality for the profile summary only (edits work for the availability and cancer experience parts of the screen. --- backend/app/routes/auth.py | 2 +- backend/app/routes/user.py | 19 +- backend/app/schemas/user_data.py | 45 +- backend/app/seeds/users.py | 135 ++- .../services/implementations/match_service.py | 74 +- .../services/implementations/user_service.py | 116 ++- backend/tests/unit/test_user_data_update.py | 745 ++++++++++++++++ frontend/package-lock.json | 8 +- frontend/package.json | 2 +- frontend/src/APIClients/authAPIClient.ts | 132 +++ frontend/src/components/admin/AdminHeader.tsx | 29 +- .../admin/userProfile/AvailabilitySection.tsx | 297 +++++++ .../userProfile/CancerExperienceSection.tsx | 366 ++++++++ .../admin/userProfile/LovedOneSection.tsx | 408 +++++++++ .../admin/userProfile/ProfileContent.tsx | 214 +++++ .../admin/userProfile/ProfileNavigation.tsx | 85 ++ .../admin/userProfile/ProfileSummary.tsx | 298 +++++++ .../admin/userProfile/SuccessMessage.tsx | 31 + .../components/ui/single-select-dropdown.tsx | 46 +- frontend/src/hooks/useAvailabilityEditing.ts | 312 +++++++ frontend/src/hooks/useIntakeOptions.ts | 26 + frontend/src/hooks/useProfileEditing.ts | 171 ++++ frontend/src/hooks/useUserProfile.ts | 27 + frontend/src/pages/admin/directory.tsx | 12 +- frontend/src/pages/admin/users/[id].tsx | 793 +++--------------- frontend/src/types/userProfileTypes.ts | 37 + frontend/src/utils/userProfileUtils.ts | 30 + 27 files changed, 3719 insertions(+), 741 deletions(-) create mode 100644 backend/tests/unit/test_user_data_update.py create mode 100644 frontend/src/components/admin/userProfile/AvailabilitySection.tsx create mode 100644 frontend/src/components/admin/userProfile/CancerExperienceSection.tsx create mode 100644 frontend/src/components/admin/userProfile/LovedOneSection.tsx create mode 100644 frontend/src/components/admin/userProfile/ProfileContent.tsx create mode 100644 frontend/src/components/admin/userProfile/ProfileNavigation.tsx create mode 100644 frontend/src/components/admin/userProfile/ProfileSummary.tsx create mode 100644 frontend/src/components/admin/userProfile/SuccessMessage.tsx create mode 100644 frontend/src/hooks/useAvailabilityEditing.ts create mode 100644 frontend/src/hooks/useIntakeOptions.ts create mode 100644 frontend/src/hooks/useProfileEditing.ts create mode 100644 frontend/src/hooks/useUserProfile.ts create mode 100644 frontend/src/types/userProfileTypes.ts create mode 100644 frontend/src/utils/userProfileUtils.ts diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index dce36793..ed916056 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -17,7 +17,7 @@ # 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 = ["umair.hkar@gmail.com", "umairmhundekar@gmail.com"] + allowed_Admins = ["umair.hkar@gmail.com", "umairmhundekar@gmail.com", "yash@kotharigroup.com"] if user.role == UserRole.ADMIN: if user.email not in allowed_Admins: raise HTTPException(status_code=403, detail="Access denied. Admin privileges required for admin portal") diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index fabdbed4..f4a5db46 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -11,6 +11,7 @@ UserRole, UserUpdateRequest, ) +from app.schemas.user_data import UserDataUpdateRequest from app.services.implementations.user_service import UserService from app.utilities.service_utils import get_user_service @@ -63,7 +64,7 @@ async def get_users( async def get_user( user_id: str, user_service: UserService = Depends(get_user_service), - # authorized: bool = has_roles([UserRole.ADMIN]), + authorized: bool = has_roles([UserRole.ADMIN]), ): try: return await user_service.get_user_by_id(user_id) @@ -89,6 +90,22 @@ async def update_user( raise HTTPException(status_code=500, detail=str(e)) +# admin only update user_data (cancer experience, treatments, experiences, etc.) +@router.patch("/{user_id}/user-data", response_model=UserResponse) +async def update_user_data( + user_id: str, + user_data_update: UserDataUpdateRequest, + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + return await user_service.update_user_data_by_id(user_id, user_data_update) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # admin only delete user @router.delete("/{user_id}") async def delete_user( diff --git a/backend/app/schemas/user_data.py b/backend/app/schemas/user_data.py index 175f7c15..873e7842 100644 --- a/backend/app/schemas/user_data.py +++ b/backend/app/schemas/user_data.py @@ -2,7 +2,7 @@ from typing import List, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class TreatmentResponse(BaseModel): @@ -68,3 +68,46 @@ class UserDataResponse(BaseModel): model_config = ConfigDict(from_attributes=True) + +class UserDataUpdateRequest(BaseModel): + """ + Request schema for user_data updates, all fields optional. + Supports partial updates for user's own data and loved one's data. + """ + + # Personal Information + first_name: Optional[str] = None + last_name: Optional[str] = None + date_of_birth: Optional[date] = None + phone: Optional[str] = None + city: Optional[str] = None + province: Optional[str] = None + postal_code: Optional[str] = None + + # Demographics + gender_identity: Optional[str] = None + pronouns: Optional[List[str]] = Field(None, description="List of pronoun strings") + ethnic_group: Optional[List[str]] = Field(None, description="List of ethnic group strings") + marital_status: Optional[str] = None + has_kids: Optional[str] = None + timezone: Optional[str] = None + + # User's Cancer Experience + diagnosis: Optional[str] = None + date_of_diagnosis: Optional[date] = None + treatments: Optional[List[str]] = Field(None, description="List of treatment names") + experiences: Optional[List[str]] = Field(None, description="List of experience names") + additional_info: Optional[str] = None + + # Loved One Demographics + loved_one_gender_identity: Optional[str] = None + loved_one_age: Optional[str] = None + + # Loved One's Cancer Experience + loved_one_diagnosis: Optional[str] = None + loved_one_date_of_diagnosis: Optional[date] = None + loved_one_treatments: Optional[List[str]] = Field(None, description="List of treatment names") + loved_one_experiences: Optional[List[str]] = Field(None, description="List of experience names") + + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 88d9f6cd..34a51920 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -10,7 +10,13 @@ from app.models.User import FormStatus, User from app.models.UserData import UserData from app.models.VolunteerData import VolunteerData +from app.models.RankingPreference import RankingPreference +from app.models.Match import Match +from app.models.FormSubmission import FormSubmission +from app.models.Task import Task +from app.models.SuggestedTime import suggested_times from app.utilities.form_constants import ExperienceId, TreatmentId +from sqlalchemy import delete def seed_users(session: Session) -> None: @@ -38,11 +44,11 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2023, 8, 10), - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6], # Chemotherapy, Radiation - "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue + "experiences": [1, 3, 4], # Brain Fog, Feeling Overwhelmed, Fatigue }, { "role": "participant", @@ -63,11 +69,11 @@ def seed_users(session: Session) -> None: "has_kids": "No", "diagnosis": "Chronic Lymphocytic Leukemia", "date_of_diagnosis": date(2024, 1, 5), - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [2, 14], # Watch and Wait, BTK Inhibitors - "experiences": [11, 12], # Anxiety/Depression, PTSD + "experiences": [10, 11], # Anxiety/Depression, PTSD }, { "role": "participant", @@ -86,8 +92,8 @@ def seed_users(session: Session) -> None: "ethnic_group": ["Hispanic/Latino"], "marital_status": "Married/Common Law", "has_kids": "Yes", - "has_blood_cancer": "No", - "caring_for_someone": "Yes", + "has_blood_cancer": "no", + "caring_for_someone": "yes", "loved_one_gender_identity": "Man", "loved_one_age": "55", "loved_one_diagnosis": "Multiple Myeloma", @@ -99,6 +105,8 @@ def seed_users(session: Session) -> None: ExperienceId.FEELING_OVERWHELMED, ExperienceId.SPEAKING_TO_FAMILY, ], + "loved_one_treatments": [3, 10], # Chemotherapy, Autologous Stem Cell Transplant + "loved_one_experiences": [3, 4, 10], # Feeling Overwhelmed, Fatigue, Anxiety/Depression }, # Volunteers { @@ -120,8 +128,8 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2018, 4, 20), # Survivor - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [ TreatmentId.CHEMOTHERAPY, @@ -154,11 +162,11 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2020, 8, 15), # Survivor - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) - "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + "experiences": [1, 3, 4], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) }, { "role": "volunteer", @@ -179,11 +187,11 @@ def seed_users(session: Session) -> None: "has_kids": "No", "diagnosis": "Hodgkin Lymphoma", "date_of_diagnosis": date(2020, 2, 14), - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6], # Chemotherapy, Radiation - "experiences": [11, 12, 8], # Anxiety/Depression, PTSD, Returning to work + "experiences": [10, 11, 7], # Anxiety/Depression, PTSD, Returning to work }, # High-matching volunteers for Sarah Johnson { @@ -205,11 +213,11 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2019, 5, 10), # Survivor - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) - "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + "experiences": [1, 3, 4], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) }, { "role": "volunteer", @@ -230,11 +238,11 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2021, 3, 18), # Survivor - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) - "experiences": [1, 4, 5, 11], # Brain Fog, Feeling Overwhelmed, Fatigue, Anxiety/Depression + "experiences": [1, 3, 4, 10], # Brain Fog, Feeling Overwhelmed, Fatigue, Anxiety/Depression }, { "role": "volunteer", @@ -255,8 +263,8 @@ def seed_users(session: Session) -> None: "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2018, 9, 25), # Survivor - "has_blood_cancer": "Yes", - "caring_for_someone": "No", + "has_blood_cancer": "yes", + "caring_for_someone": "no", }, "treatments": [3, 6, 7], # Chemotherapy, Radiation, Maintenance Chemo "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) @@ -279,8 +287,8 @@ def seed_users(session: Session) -> None: "ethnic_group": ["White/Caucasian"], "marital_status": "Married/Common Law", "has_kids": "Yes", - "has_blood_cancer": "No", # Not a cancer patient herself - "caring_for_someone": "Yes", # Is a caregiver + "has_blood_cancer": "no", # Not a cancer patient herself + "caring_for_someone": "yes", # Is a caregiver "loved_one_gender_identity": "Woman", "loved_one_age": "45", "loved_one_diagnosis": "Breast Cancer", @@ -292,6 +300,8 @@ def seed_users(session: Session) -> None: ExperienceId.FEELING_OVERWHELMED, ExperienceId.ANXIETY_DEPRESSION, ], + "loved_one_treatments": [3, 6], # Chemotherapy, Radiation + "loved_one_experiences": [3, 4], # Feeling Overwhelmed, Fatigue }, ] @@ -304,8 +314,60 @@ def seed_users(session: Session) -> None: # Check if user already exists existing_user = session.query(User).filter_by(email=user_info["user_data"]["email"]).first() if existing_user: - print(f"User already exists: {user_info['user_data']['email']}") - continue + print(f"User already exists, overwriting: {user_info['user_data']['email']}") + user_id = existing_user.id + + # Manually delete all related data first (since cascade delete may not be configured) + # Delete ranking preferences + session.query(RankingPreference).filter(RankingPreference.user_id == user_id).delete() + + # Get matches that need to be deleted (to delete suggested_times first) + matches_to_delete = session.query(Match).filter( + (Match.participant_id == user_id) | (Match.volunteer_id == user_id) + ).all() + + # Delete suggested_times for these matches first (must be done before deleting matches) + # Use raw SQL to delete from suggested_times table to avoid relationship issues + match_ids = [match.id for match in matches_to_delete] + if match_ids: + session.execute( + delete(suggested_times).where(suggested_times.c.match_id.in_(match_ids)) + ) + session.flush() # Ensure suggested_times deletions are processed + + # Now delete the matches (after suggested_times are cleared) + for match in matches_to_delete: + session.delete(match) + + # Delete form submissions + session.query(FormSubmission).filter(FormSubmission.user_id == user_id).delete() + + # Delete tasks (as participant or assignee) + session.query(Task).filter( + (Task.participant_id == user_id) | (Task.assignee_id == user_id) + ).delete() + + # Delete user_data and its relationships + if existing_user.user_data: + # Clear many-to-many relationships first + existing_user.user_data.treatments.clear() + existing_user.user_data.experiences.clear() + existing_user.user_data.loved_one_treatments.clear() + existing_user.user_data.loved_one_experiences.clear() + session.delete(existing_user.user_data) + + # Delete volunteer_data + if existing_user.volunteer_data: + session.delete(existing_user.volunteer_data) + + # Clear availability relationships (delete time blocks) + if existing_user.availability: + for time_block in list(existing_user.availability): + session.delete(time_block) + + # Now delete the user + session.delete(existing_user) + session.flush() # Ensure deletion is processed before creating new user # Create user user = User( @@ -325,6 +387,8 @@ def seed_users(session: Session) -> None: # Create user data user_data = UserData( user_id=user.id, + first_name=user_info["user_data"]["first_name"], + last_name=user_info["user_data"]["last_name"], **{ k: v for k, v in user_info["user_data"].items() @@ -332,17 +396,28 @@ def seed_users(session: Session) -> None: }, ) session.add(user_data) + session.flush() # Ensure user_data has an ID before assigning relationships # Add treatments if they exist - if user_info["treatments"]: + if user_info.get("treatments"): treatments = session.query(Treatment).filter(Treatment.id.in_(user_info["treatments"])).all() user_data.treatments = treatments # Add experiences if they exist - if user_info["experiences"]: + if user_info.get("experiences"): experiences = session.query(Experience).filter(Experience.id.in_(user_info["experiences"])).all() user_data.experiences = experiences + # Add loved one treatments if they exist + if user_info.get("loved_one_treatments"): + loved_one_treatments = session.query(Treatment).filter(Treatment.id.in_(user_info["loved_one_treatments"])).all() + user_data.loved_one_treatments = loved_one_treatments + + # Add loved one experiences if they exist + if user_info.get("loved_one_experiences"): + loved_one_experiences = session.query(Experience).filter(Experience.id.in_(user_info["loved_one_experiences"])).all() + user_data.loved_one_experiences = loved_one_experiences + # Create volunteer_data entry for volunteers with experience text if user_info["role"] == "volunteer": volunteer_experience_text = user_info.get( diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index 7ff0bdb1..7af6e3c5 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -796,21 +796,69 @@ def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None return now = datetime.now(timezone.utc) - sorted_blocks = sorted( - volunteer.availability, - key=lambda tb: tb.start_time or now, - ) + + # Define the projection window (e.g., next 2 weeks) + projection_weeks = 2 + + # Filter for template blocks (blocks in the past, specifically our reference week in 2000) + # We can just check if the year is 2000, or generally if it's far in the past. + # For robustness, let's assume any block before "now" is potentially a template if we are using this system. + # But strictly, our frontend sends 2000-01-XX. + + template_blocks = [ + tb for tb in volunteer.availability + if tb.start_time and tb.start_time.year == 2000 + ] - for block in sorted_blocks: - if block.start_time is None: - continue - if block.start_time < now: - continue - if block.start_time.minute not in {0, 30}: - continue + if not template_blocks: + # Fallback for legacy data: use existing logic for non-template blocks + sorted_blocks = sorted( + volunteer.availability, + key=lambda tb: tb.start_time or now, + ) + for block in sorted_blocks: + if block.start_time is None: + continue + if block.start_time < now: + continue + if block.start_time.minute not in {0, 30}: + continue + new_block = TimeBlock(start_time=block.start_time) + match.suggested_time_blocks.append(new_block) + return - new_block = TimeBlock(start_time=block.start_time) - match.suggested_time_blocks.append(new_block) + # Project template blocks onto the next `projection_weeks` weeks + # Find the next Monday to start the cycle + # If today is Monday, start today. If today is Tuesday, start next Monday? + # Usually availability is "next 2 weeks". Let's start from "tomorrow" or "today" and find matching days. + + # Let's iterate through the next 14 days + for day_offset in range(projection_weeks * 7): + target_date = now + timedelta(days=day_offset) + target_day_of_week = target_date.weekday() # 0=Mon, 6=Sun + + # Find templates that match this day of week + # Template reference: Jan 3, 2000 was a Monday. + # Jan 3 (Mon) -> weekday 0 + # ... + # Jan 9 (Sun) -> weekday 6 + + for template in template_blocks: + if template.start_time.weekday() == target_day_of_week: + # Create a new block for this target date with the template's time + new_start_time = target_date.replace( + hour=template.start_time.hour, + minute=template.start_time.minute, + second=0, + microsecond=0 + ) + + # Ensure we don't add blocks in the past (if we started from 'now' and time has passed today) + if new_start_time < now: + continue + + new_block = TimeBlock(start_time=new_start_time) + match.suggested_time_blocks.append(new_block) def _reassign_volunteer(self, match: Match, volunteer: User) -> None: match.volunteer_id = volunteer.id diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 1ee65da5..470d8800 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, joinedload from app.interfaces.user_service import IUserService -from app.models import FormStatus, Role, User, UserData, VolunteerData +from app.models import FormStatus, Role, User, UserData, VolunteerData, Treatment, Experience from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -16,6 +16,7 @@ UserRole, UserUpdateRequest, ) +from app.schemas.user_data import UserDataUpdateRequest from app.utilities.constants import LOGGER_NAME @@ -255,3 +256,116 @@ async def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) self.db.rollback() self.logger.error(f"Error updating user {user_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + + async def update_user_data_by_id(self, user_id: str, user_data_update: UserDataUpdateRequest) -> UserResponse: + """ + Update user_data fields for a user. Handles partial updates including + treatments and experiences (many-to-many relationships). + """ + try: + db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + # Get or create UserData + user_data = self.db.query(UserData).filter(UserData.user_id == UUID(user_id)).first() + if not user_data: + user_data = UserData(user_id=UUID(user_id)) + self.db.add(user_data) + self.db.flush() + + update_data = user_data_update.model_dump(exclude_unset=True) + + # Update simple fields (personal info, demographics, cancer experience) + simple_fields = [ + # Personal Information + 'first_name', 'last_name', 'date_of_birth', 'phone', 'city', 'province', 'postal_code', + # Demographics + 'gender_identity', 'marital_status', 'has_kids', 'timezone', + # Cancer Experience + 'diagnosis', 'date_of_diagnosis', 'additional_info', + # Loved One Demographics + 'loved_one_gender_identity', 'loved_one_age', + # Loved One Cancer Experience + 'loved_one_diagnosis', 'loved_one_date_of_diagnosis' + ] + for field in simple_fields: + if field in update_data: + setattr(user_data, field, update_data[field]) + + # Handle pronouns (array field) + if 'pronouns' in update_data: + user_data.pronouns = update_data['pronouns'] + + # Handle ethnic_group (array field) + if 'ethnic_group' in update_data: + user_data.ethnic_group = update_data['ethnic_group'] + + # Handle treatments (many-to-many) + if 'treatments' in update_data: + user_data.treatments.clear() + if update_data['treatments']: + for treatment_name in update_data['treatments']: + if treatment_name: + treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() + if treatment: + user_data.treatments.append(treatment) + + # Handle experiences (many-to-many) + if 'experiences' in update_data: + user_data.experiences.clear() + if update_data['experiences']: + for experience_name in update_data['experiences']: + if experience_name: + experience = self.db.query(Experience).filter(Experience.name == experience_name).first() + if experience: + user_data.experiences.append(experience) + + # Handle loved one treatments (many-to-many) + if 'loved_one_treatments' in update_data: + user_data.loved_one_treatments.clear() + if update_data['loved_one_treatments']: + for treatment_name in update_data['loved_one_treatments']: + if treatment_name: + treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() + if treatment: + user_data.loved_one_treatments.append(treatment) + + # Handle loved one experiences (many-to-many) + if 'loved_one_experiences' in update_data: + user_data.loved_one_experiences.clear() + if update_data['loved_one_experiences']: + for experience_name in update_data['loved_one_experiences']: + if experience_name: + experience = self.db.query(Experience).filter(Experience.name == experience_name).first() + if experience: + user_data.loved_one_experiences.append(experience) + + self.db.commit() + self.db.refresh(db_user) + + # Return updated user with all relationships loaded + updated_user = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.user_data).joinedload(UserData.treatments), + joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(User.user_data).joinedload(UserData.loved_one_treatments), + joinedload(User.user_data).joinedload(UserData.loved_one_experiences), + joinedload(User.volunteer_data), + joinedload(User.availability), + ) + .filter(User.id == UUID(user_id)) + .first() + ) + return UserResponse.model_validate(updated_user) + + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID format") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error updating user_data for user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/tests/unit/test_user_data_update.py b/backend/tests/unit/test_user_data_update.py new file mode 100644 index 00000000..1b8d3c18 --- /dev/null +++ b/backend/tests/unit/test_user_data_update.py @@ -0,0 +1,745 @@ +import os +import pytest +from datetime import date, datetime, timedelta, timezone +from uuid import uuid4 +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +from app.models import Role, User, UserData, Treatment, Experience, TimeBlock +from app.schemas.user import UserRole +from app.schemas.user_data import UserDataUpdateRequest +from app.schemas.availability import CreateAvailabilityRequest, DeleteAvailabilityRequest +from app.schemas.time_block import TimeRange +from app.services.implementations.user_service import UserService +from app.services.implementations.availability_service import AvailabilityService + +# Test DB Configuration - Always require Postgres for full parity +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +if not POSTGRES_DATABASE_URL: + raise RuntimeError( + "POSTGRES_TEST_DATABASE_URL is not set. Please export a Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + ) +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test""" + from sqlalchemy import text + from sqlalchemy.exc import IntegrityError + + session = TestingSessionLocal() + + try: + # Clean up any existing data first + session.execute( + text( + "TRUNCATE TABLE user_loved_one_experiences, user_loved_one_treatments, " + "user_experiences, user_treatments, available_times, time_blocks, " + "user_data, users RESTART IDENTITY CASCADE" + ) + ) + session.commit() + + # Ensure roles exist + existing = {r.id for r in session.query(Role).all()} + seed_roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for role in seed_roles: + if role.id not in existing: + try: + session.add(role) + session.commit() + except IntegrityError: + session.rollback() + + # Create test treatments (using IDs that don't conflict with seeded data) + treatments = [ + Treatment(id=100, name="Chemotherapy"), + Treatment(id=101, name="Radiation"), + Treatment(id=102, name="Immunotherapy"), + Treatment(id=103, name="Oral Chemotherapy"), + ] + for treatment in treatments: + try: + session.add(treatment) + session.commit() + except IntegrityError: + session.rollback() + + # Create test experiences (using IDs that don't conflict with seeded data) + experiences = [ + Experience(id=100, name="Fatigue", scope="both"), + Experience(id=101, name="Anxiety / Depression", scope="both"), # Match seeded data + Experience(id=102, name="Brain Fog", scope="both"), + Experience(id=103, name="Depression", scope="both"), + ] + for experience in experiences: + try: + session.add(experience) + session.commit() + except IntegrityError: + session.rollback() + + yield session + finally: + session.rollback() + session.close() + + +@pytest.fixture +def test_user_with_data(db_session): + """Create a test user with existing user_data""" + user = User( + id=uuid4(), + auth_id="test-auth-id", + email="test@example.com", + role_id=1, # PARTICIPANT + first_name="John", + last_name="Doe", + ) + db_session.add(user) + db_session.flush() + + user_data = UserData( + user_id=user.id, + first_name="John", + last_name="Doe", + date_of_birth=date(1990, 1, 1), + phone="123-456-7890", + gender_identity="Male", + pronouns=["he/him"], + ethnic_group=["White"], + marital_status="Single", + has_kids="No", + diagnosis="Acute Myeloid Leukemia", + date_of_diagnosis=date(2020, 1, 1), + additional_info="Some additional info", + ) + db_session.add(user_data) + db_session.flush() + + # Add existing treatments and experiences + treatment1 = db_session.query(Treatment).filter(Treatment.name == "Chemotherapy").first() + treatment2 = db_session.query(Treatment).filter(Treatment.name == "Radiation").first() + if treatment1: + user_data.treatments.append(treatment1) + if treatment2: + user_data.treatments.append(treatment2) + + experience1 = db_session.query(Experience).filter(Experience.name == "Fatigue").first() + # Note: The seeded experience is "Anxiety / Depression" not just "Anxiety" + experience2 = db_session.query(Experience).filter(Experience.name == "Anxiety / Depression").first() + if experience1: + user_data.experiences.append(experience1) + if experience2: + user_data.experiences.append(experience2) + + db_session.commit() + db_session.refresh(user) + db_session.refresh(user_data) + return user, user_data + + +@pytest.mark.asyncio +async def test_update_simple_fields(db_session, test_user_with_data): + """Test updating simple fields like first_name, phone, etc.""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + update_request = UserDataUpdateRequest( + first_name="Jane", + phone="987-654-3210", + city="Toronto", + province="ON", + postal_code="M5H 2N2", + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.first_name == "Jane" + assert result.user_data.phone == "987-654-3210" + assert result.user_data.city == "Toronto" + assert result.user_data.province == "ON" + assert result.user_data.postal_code == "M5H 2N2" + # Verify other fields unchanged + assert result.user_data.last_name == "Doe" + assert result.user_data.date_of_birth == date(1990, 1, 1) + + +@pytest.mark.asyncio +async def test_update_array_fields(db_session, test_user_with_data): + """Test updating array fields like pronouns and ethnic_group""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + update_request = UserDataUpdateRequest( + pronouns=["she/her", "they/them"], + ethnic_group=["Asian", "Pacific Islander"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.pronouns == ["she/her", "they/them"] + assert result.user_data.ethnic_group == ["Asian", "Pacific Islander"] + # Verify old values are replaced + assert "he/him" not in result.user_data.pronouns + assert "White" not in result.user_data.ethnic_group + + +@pytest.mark.asyncio +async def test_update_treatments_clears_old_and_adds_new(db_session, test_user_with_data): + """Test that updating treatments clears old ones and adds new ones""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Verify initial state + initial_treatment_names = {t.name for t in user_data.treatments} + assert "Chemotherapy" in initial_treatment_names + assert "Radiation" in initial_treatment_names + assert len(initial_treatment_names) == 2 + + # Update with new treatments (using names that exist in seeded data) + update_request = UserDataUpdateRequest( + treatments=["Immunotherapy", "Oral Chemotherapy"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + result_treatment_names = {t.name for t in result.user_data.treatments} + + # Verify old treatments are removed + assert "Chemotherapy" not in result_treatment_names + assert "Radiation" not in result_treatment_names + + # Verify new treatments are added + assert "Immunotherapy" in result_treatment_names + assert "Oral Chemotherapy" in result_treatment_names + assert len(result_treatment_names) == 2 + + +@pytest.mark.asyncio +async def test_update_experiences_clears_old_and_adds_new(db_session, test_user_with_data): + """Test that updating experiences clears old ones and adds new ones""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Verify initial state + initial_experience_names = {e.name for e in user_data.experiences} + assert "Fatigue" in initial_experience_names + assert "Anxiety / Depression" in initial_experience_names + assert len(initial_experience_names) == 2 + + # Update with new experiences (using names that exist in seeded data) + update_request = UserDataUpdateRequest( + experiences=["Brain Fog", "Feeling Overwhelmed"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + result_experience_names = {e.name for e in result.user_data.experiences} + + # Verify old experiences are removed + assert "Fatigue" not in result_experience_names + assert "Anxiety / Depression" not in result_experience_names + + # Verify new experiences are added + assert "Brain Fog" in result_experience_names + assert "Feeling Overwhelmed" in result_experience_names + assert len(result_experience_names) == 2 + + +@pytest.mark.asyncio +async def test_update_treatments_with_empty_list_clears_all(db_session, test_user_with_data): + """Test that passing empty list clears all treatments""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Verify initial state has treatments + assert len(user_data.treatments) == 2 + + # Update with empty list + update_request = UserDataUpdateRequest(treatments=[]) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert len(result.user_data.treatments) == 0 + + +@pytest.mark.asyncio +async def test_update_loved_one_treatments(db_session, test_user_with_data): + """Test updating loved one treatments""" + user, user_data = test_user_with_data + + # Add initial loved one treatments + treatment1 = db_session.query(Treatment).filter(Treatment.name == "Chemotherapy").first() + user_data.loved_one_treatments.append(treatment1) + db_session.commit() + + user_service = UserService(db_session) + + # Update with new loved one treatments + update_request = UserDataUpdateRequest( + loved_one_treatments=["Radiation", "Immunotherapy"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + result_treatment_names = {t.name for t in result.user_data.loved_one_treatments} + + # Verify old treatment is removed + assert "Chemotherapy" not in result_treatment_names + + # Verify new treatments are added + assert "Radiation" in result_treatment_names + assert "Immunotherapy" in result_treatment_names + assert len(result_treatment_names) == 2 + + +@pytest.mark.asyncio +async def test_update_loved_one_experiences(db_session, test_user_with_data): + """Test updating loved one experiences""" + user, user_data = test_user_with_data + + # Add initial loved one experiences + experience1 = db_session.query(Experience).filter(Experience.name == "Fatigue").first() + user_data.loved_one_experiences.append(experience1) + db_session.commit() + + user_service = UserService(db_session) + + # Update with new loved one experiences + update_request = UserDataUpdateRequest( + loved_one_experiences=["Anxiety / Depression", "Brain Fog"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + result_experience_names = {e.name for e in result.user_data.loved_one_experiences} + + # Verify old experience is removed + assert "Fatigue" not in result_experience_names + + # Verify new experiences are added + assert "Anxiety / Depression" in result_experience_names + assert "Brain Fog" in result_experience_names + assert len(result_experience_names) == 2 + + +@pytest.mark.asyncio +async def test_update_partial_fields_preserves_others(db_session, test_user_with_data): + """Test that partial updates don't affect other fields""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Only update diagnosis, treatments should remain unchanged + update_request = UserDataUpdateRequest( + diagnosis="Chronic Lymphocytic Leukemia", + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.diagnosis == "Chronic Lymphocytic Leukemia" + # Verify treatments are preserved + assert len(result.user_data.treatments) == 2 + treatment_names = {t.name for t in result.user_data.treatments} + assert "Chemotherapy" in treatment_names + assert "Radiation" in treatment_names + + +@pytest.mark.asyncio +async def test_update_creates_user_data_if_not_exists(db_session): + """Test that update creates UserData if it doesn't exist""" + user = User( + id=uuid4(), + auth_id="new-user-auth-id", + email="newuser@example.com", + role_id=1, + first_name="New", + last_name="User", + ) + db_session.add(user) + db_session.commit() + + user_service = UserService(db_session) + + update_request = UserDataUpdateRequest( + first_name="Updated", + diagnosis="Test Diagnosis", + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.first_name == "Updated" + assert result.user_data.diagnosis == "Test Diagnosis" + + +@pytest.mark.asyncio +async def test_update_with_invalid_treatment_name_ignores_it(db_session, test_user_with_data): + """Test that invalid treatment names are ignored (not added)""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Mix valid and invalid treatment names + update_request = UserDataUpdateRequest( + treatments=["Chemotherapy", "Invalid Treatment Name", "Radiation"], + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + treatment_names = {t.name for t in result.user_data.treatments} + + # Only valid treatments should be added + assert "Chemotherapy" in treatment_names + assert "Radiation" in treatment_names + assert "Invalid Treatment Name" not in treatment_names + assert len(treatment_names) == 2 + + +@pytest.mark.asyncio +async def test_update_user_not_found_raises_error(db_session): + """Test that updating non-existent user raises 404""" + user_service = UserService(db_session) + fake_user_id = str(uuid4()) + + update_request = UserDataUpdateRequest(first_name="Test") + + with pytest.raises(Exception) as exc_info: + await user_service.update_user_data_by_id(fake_user_id, update_request) + + assert "not found" in str(exc_info.value).lower() or exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_loved_one_fields(db_session, test_user_with_data): + """Test updating loved one specific fields""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + update_request = UserDataUpdateRequest( + loved_one_gender_identity="Female", + loved_one_age="45", + loved_one_diagnosis="Chronic Myeloid Leukemia", + loved_one_date_of_diagnosis=date(2019, 6, 15), + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.loved_one_gender_identity == "Female" + assert result.user_data.loved_one_age == "45" + assert result.user_data.loved_one_diagnosis == "Chronic Myeloid Leukemia" + assert result.user_data.loved_one_date_of_diagnosis == date(2019, 6, 15) + + +@pytest.mark.asyncio +async def test_update_date_fields(db_session, test_user_with_data): + """Test updating date fields""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + new_date = date(2021, 5, 20) + update_request = UserDataUpdateRequest( + date_of_birth=date(1985, 3, 10), + date_of_diagnosis=new_date, + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + assert result.user_data is not None + assert result.user_data.date_of_birth == date(1985, 3, 10) + assert result.user_data.date_of_diagnosis == new_date + + +# ========== AVAILABILITY TESTS ========== + + +@pytest.fixture +def volunteer_user(db_session): + """Create a volunteer user for availability tests""" + user = User( + id=uuid4(), + auth_id="volunteer-auth-id", + email="volunteer@example.com", + role_id=2, # VOLUNTEER + first_name="Volunteer", + last_name="Test", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.mark.asyncio +async def test_create_availability_adds_time_blocks(db_session, volunteer_user): + """Test that creating availability adds time blocks correctly""" + availability_service = AvailabilityService(db_session) + + # Create a time range: tomorrow 10:00 AM to 11:30 AM (should create 3 blocks: 10:00, 10:30, 11:00) + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=11, minute=30, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + + result = await availability_service.create_availability(create_request) + + assert result.user_id == volunteer_user.id + assert result.added == 3 # 10:00, 10:30, 11:00 + + # Verify time blocks were created + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 3 + start_times = {tb.start_time for tb in volunteer_user.availability} + assert start_time in start_times + assert start_time + timedelta(minutes=30) in start_times + assert start_time + timedelta(hours=1) in start_times + + +@pytest.mark.asyncio +async def test_create_availability_ignores_existing_blocks(db_session, volunteer_user): + """Test that creating availability ignores existing time blocks""" + availability_service = AvailabilityService(db_session) + + # Create an existing time block + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + existing_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + existing_block = TimeBlock(start_time=existing_time) + volunteer_user.availability.append(existing_block) + db_session.commit() + + # Try to create availability that includes the existing block + start_time = existing_time + end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + + result = await availability_service.create_availability(create_request) + + # Should only add 1 new block (10:30), not the existing 10:00 + assert result.added == 1 + + # Verify we still have 2 blocks total + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 2 + + +@pytest.mark.asyncio +async def test_create_availability_multiple_ranges(db_session, volunteer_user): + """Test creating availability with multiple time ranges""" + availability_service = AvailabilityService(db_session) + + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + + # Create two separate time ranges + range1_start = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + range1_end = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + range2_start = tomorrow.replace(hour=14, minute=0, second=0, microsecond=0) + range2_end = tomorrow.replace(hour=15, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[ + TimeRange(start_time=range1_start, end_time=range1_end), + TimeRange(start_time=range2_start, end_time=range2_end), + ], + ) + + result = await availability_service.create_availability(create_request) + + # Should add 4 blocks total (2 from each range) + assert result.added == 4 + + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 4 + + +@pytest.mark.asyncio +async def test_delete_availability_removes_time_blocks(db_session, volunteer_user): + """Test that deleting availability removes time blocks correctly""" + availability_service = AvailabilityService(db_session) + + # First, create some availability + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=12, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + await availability_service.create_availability(create_request) + + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 4 # 10:00, 10:30, 11:00, 11:30 + + # Now delete a portion of it (10:00 to 11:00, should remove 2 blocks) + delete_start = start_time + delete_end = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + delete=[TimeRange(start_time=delete_start, end_time=delete_end)], + ) + + result = await availability_service.delete_availability(delete_request) + + assert result.user_id == volunteer_user.id + assert result.deleted == 2 # Removed 10:00 and 10:30 + + # Verify remaining blocks + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 2 # Should have 11:00 and 11:30 left + remaining_times = {tb.start_time for tb in volunteer_user.availability} + assert tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) in remaining_times + assert tomorrow.replace(hour=11, minute=30, second=0, microsecond=0) in remaining_times + + +@pytest.mark.asyncio +async def test_delete_availability_ignores_non_existent_blocks(db_session, volunteer_user): + """Test that deleting availability ignores non-existent time blocks""" + availability_service = AvailabilityService(db_session) + + # Create some availability + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + await availability_service.create_availability(create_request) + + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 2 + + # Try to delete a time range that doesn't exist (14:00 to 15:00) + delete_start = tomorrow.replace(hour=14, minute=0, second=0, microsecond=0) + delete_end = tomorrow.replace(hour=15, minute=0, second=0, microsecond=0) + + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + delete=[TimeRange(start_time=delete_start, end_time=delete_end)], + ) + + result = await availability_service.delete_availability(delete_request) + + # Should delete 0 blocks since none exist in that range + assert result.deleted == 0 + + # Verify original blocks are still there + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 2 + + +@pytest.mark.asyncio +async def test_delete_all_availability(db_session, volunteer_user): + """Test deleting all availability""" + availability_service = AvailabilityService(db_session) + + # Create availability + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=12, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + await availability_service.create_availability(create_request) + + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 4 + + # Delete all availability + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + delete=[TimeRange(start_time=start_time, end_time=end_time)], + ) + + result = await availability_service.delete_availability(delete_request) + + assert result.deleted == 4 + + # Verify all blocks are removed + db_session.refresh(volunteer_user) + assert len(volunteer_user.availability) == 0 + # Note: result.availability might contain stale data before refresh, so we check the refreshed user instead + + +@pytest.mark.asyncio +async def test_delete_availability_user_not_found(db_session): + """Test that deleting availability raises error for non-existent user""" + availability_service = AvailabilityService(db_session) + + fake_user_id = uuid4() + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + delete_request = DeleteAvailabilityRequest( + user_id=fake_user_id, + delete=[TimeRange(start_time=start_time, end_time=end_time)], + ) + + with pytest.raises(Exception) as exc_info: + await availability_service.delete_availability(delete_request) + + # The service currently raises 500 for user not found (could be improved to 404) + assert exc_info.value.status_code == 500 + + +@pytest.mark.asyncio +async def test_create_availability_user_not_found(db_session): + """Test that creating availability raises error for non-existent user""" + availability_service = AvailabilityService(db_session) + + fake_user_id = uuid4() + now = datetime.now(timezone.utc) + tomorrow = now + timedelta(days=1) + start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) + end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + + create_request = CreateAvailabilityRequest( + user_id=fake_user_id, + available_times=[TimeRange(start_time=start_time, end_time=end_time)], + ) + + with pytest.raises(Exception) as exc_info: + await availability_service.create_availability(create_request) + + # The service currently raises 500 for user not found (could be improved to 404) + assert exc_info.value.status_code == 500 + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fbca868b..3a193cc1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,7 @@ "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", - "react-datepicker": "^8.7.0", + "react-datepicker": "^8.9.0", "react-dom": "^18", "react-hook-form": "^7.57.0", "react-icons": "^5.5.0" @@ -6595,9 +6595,9 @@ } }, "node_modules/react-datepicker": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.7.0.tgz", - "integrity": "sha512-r5OJbiLWc3YiVNy69Kau07/aVgVGsFVMA6+nlqCV7vyQ8q0FUOnJ+wAI4CgVxHejG3i5djAEiebrF8/Eip4rIw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz", + "integrity": "sha512-yoRsGxjqVRjk8iUBssrW9jcinTeyP9mAfTpuzdKvlESOUjdrY0sfDTzIZWJAn38jvNcxW1dnDmW1CinjiFdxYQ==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.15", diff --git a/frontend/package.json b/frontend/package.json index cff8b022..32fd478e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", - "react-datepicker": "^8.7.0", + "react-datepicker": "^8.9.0", "react-dom": "^18", "react-hook-form": "^7.57.0", "react-icons": "^5.5.0" diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 36dfc1f1..09220729 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -431,3 +431,135 @@ export const getUserById = async (userId: string): Promise => { const response = await baseAPIClient.get(`/users/${userId}`); return response.data; }; + +/** + * Update user data (profile information, cancer experience, etc.) + */ +export const updateUserData = async ( + userId: string, + userDataUpdate: { + firstName?: string; + lastName?: string; + dateOfBirth?: string; + phone?: string; + city?: string; + province?: string; + postalCode?: string; + genderIdentity?: string; + pronouns?: string[]; + ethnicGroup?: string[]; + maritalStatus?: string; + hasKids?: string; + timezone?: string; + diagnosis?: string; + dateOfDiagnosis?: string; + treatments?: string[]; + experiences?: string[]; + additionalInfo?: string; + lovedOneGenderIdentity?: string; + lovedOneAge?: string; + lovedOneDiagnosis?: string; + lovedOneDateOfDiagnosis?: string; + lovedOneTreatments?: string[]; + lovedOneExperiences?: string[]; + } +): Promise => { + // Convert camelCase to snake_case for backend + const backendData: Record = {}; + + if (userDataUpdate.firstName !== undefined) backendData.first_name = userDataUpdate.firstName; + if (userDataUpdate.lastName !== undefined) backendData.last_name = userDataUpdate.lastName; + if (userDataUpdate.dateOfBirth !== undefined) backendData.date_of_birth = userDataUpdate.dateOfBirth; + if (userDataUpdate.phone !== undefined) backendData.phone = userDataUpdate.phone; + if (userDataUpdate.city !== undefined) backendData.city = userDataUpdate.city; + if (userDataUpdate.province !== undefined) backendData.province = userDataUpdate.province; + if (userDataUpdate.postalCode !== undefined) backendData.postal_code = userDataUpdate.postalCode; + if (userDataUpdate.genderIdentity !== undefined) backendData.gender_identity = userDataUpdate.genderIdentity; + if (userDataUpdate.pronouns !== undefined) backendData.pronouns = userDataUpdate.pronouns; + if (userDataUpdate.ethnicGroup !== undefined) backendData.ethnic_group = userDataUpdate.ethnicGroup; + if (userDataUpdate.maritalStatus !== undefined) backendData.marital_status = userDataUpdate.maritalStatus; + if (userDataUpdate.hasKids !== undefined) backendData.has_kids = userDataUpdate.hasKids; + if (userDataUpdate.timezone !== undefined) backendData.timezone = userDataUpdate.timezone; + if (userDataUpdate.diagnosis !== undefined) backendData.diagnosis = userDataUpdate.diagnosis; + if (userDataUpdate.dateOfDiagnosis !== undefined) { + // Convert null to null (to clear date) or keep the date string + backendData.date_of_diagnosis = userDataUpdate.dateOfDiagnosis; + } + if (userDataUpdate.treatments !== undefined) backendData.treatments = userDataUpdate.treatments; + if (userDataUpdate.experiences !== undefined) backendData.experiences = userDataUpdate.experiences; + if (userDataUpdate.additionalInfo !== undefined) backendData.additional_info = userDataUpdate.additionalInfo; + if (userDataUpdate.lovedOneGenderIdentity !== undefined) backendData.loved_one_gender_identity = userDataUpdate.lovedOneGenderIdentity; + if (userDataUpdate.lovedOneAge !== undefined) backendData.loved_one_age = userDataUpdate.lovedOneAge; + if (userDataUpdate.lovedOneDiagnosis !== undefined) backendData.loved_one_diagnosis = userDataUpdate.lovedOneDiagnosis; + if (userDataUpdate.lovedOneDateOfDiagnosis !== undefined) { + // Convert null to null (to clear date) or keep the date string + backendData.loved_one_date_of_diagnosis = userDataUpdate.lovedOneDateOfDiagnosis; + } + if (userDataUpdate.lovedOneTreatments !== undefined) backendData.loved_one_treatments = userDataUpdate.lovedOneTreatments; + if (userDataUpdate.lovedOneExperiences !== undefined) backendData.loved_one_experiences = userDataUpdate.lovedOneExperiences; + + const response = await baseAPIClient.patch(`/users/${userId}/user-data`, backendData); + return response.data; +}; + +/** + * Availability API types and functions + */ +export interface TimeRange { + startTime: string; // ISO datetime string + endTime: string; // ISO datetime string +} + +export interface CreateAvailabilityRequest { + userId: string; + availableTimes: TimeRange[]; +} + +export interface DeleteAvailabilityRequest { + userId: string; + delete: TimeRange[]; +} + +/** + * Get availability for a user + */ +export const getAvailability = async (userId: string): Promise<{ availableTimes: Array<{ id: number; startTime: string }> }> => { + const response = await baseAPIClient.get<{ userId: string; availableTimes: Array<{ id: number; startTime: string }> }>(`/availability?user_id=${userId}`); + return response.data; +}; + +/** + * Create availability for a user + */ +export const createAvailability = async (request: CreateAvailabilityRequest): Promise<{ userId: string; added: number }> => { + // Convert camelCase to snake_case for backend + const backendData = { + user_id: request.userId, + available_times: request.availableTimes.map(range => ({ + start_time: range.startTime, + end_time: range.endTime, + })), + }; + const response = await baseAPIClient.post<{ user_id: string; added: number }>('/availability', backendData); + return { userId: response.data.user_id, added: response.data.added }; +}; + +/** + * Delete availability for a user + */ +export const deleteAvailability = async (request: DeleteAvailabilityRequest): Promise<{ userId: string; deleted: number; availability: Array<{ id: number; startTime: string }> }> => { + // Convert camelCase to snake_case for backend + const backendData = { + user_id: request.userId, + delete: request.delete.map(range => ({ + start_time: range.startTime, + end_time: range.endTime, + })), + }; + const response = await baseAPIClient.delete<{ user_id: string; deleted: number; availability: Array<{ id: number; start_time: string }> }>('/availability', { data: backendData }); + return { + userId: response.data.user_id, + deleted: response.data.deleted, + availability: response.data.availability.map(block => ({ id: block.id, startTime: block.start_time })), + }; +}; diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index beb1fd74..81689c36 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -1,11 +1,22 @@ import React from 'react'; import Image from 'next/image'; import { Box, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import { FiFolder, FiLoader, FiLogOut } from 'react-icons/fi'; import { LabelSmall } from '@/components/ui/text-styles'; import { COLORS, shadow } from '@/constants/colors'; export const AdminHeader: React.FC = () => { + const router = useRouter(); + + const handleTaskListClick = () => { + router.push('/admin/tasks'); + }; + + const handleProgressTrackerClick = () => { + router.push('/admin/directory'); + }; + return ( { {/* Navigation Items */} - + Task List - + Progress Tracker diff --git a/frontend/src/components/admin/userProfile/AvailabilitySection.tsx b/frontend/src/components/admin/userProfile/AvailabilitySection.tsx new file mode 100644 index 00000000..7bc6dc34 --- /dev/null +++ b/frontend/src/components/admin/userProfile/AvailabilitySection.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, + HStack, + Badge, + Grid, + GridItem, +} from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; +import { UserResponse } from '@/types/userTypes'; + +interface AvailabilitySectionProps { + user: UserResponse; + isEditing: boolean; + isSaving: boolean; + selectedTimeSlots: Set; + isDragging: boolean; + dragStart: { dayIndex: number; timeIndex: number } | null; + getDragRangeSlots: () => Set; + onStartEdit: () => void; + onCancelEdit: () => void; + onSave: () => void; + onMouseDown: (dayIndex: number, timeIndex: number) => void; + onMouseMove: (dayIndex: number, timeIndex: number) => void; + onMouseUp: () => void; +} + +export function AvailabilitySection({ + user, + isEditing, + isSaving, + selectedTimeSlots, + isDragging, + dragStart, + getDragRangeSlots, + onStartEdit, + onCancelEdit, + onSave, + onMouseDown, + onMouseMove, + onMouseUp, +}: AvailabilitySectionProps) { + return ( + + + + Availability + + {isEditing ? ( + + + + + ) : ( + + )} + + + + {/* Grid */} + { + // Cancel drag if mouse leaves the grid - handled by hook + }} + onMouseUp={() => { + // Handle mouse up anywhere in the grid - handled by hook + }} + > + + {/* Header Row */} + + EST + + {['Mon', 'Tues', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => ( + + {day} + + ))} + + {/* Time Rows */} + {[ + '8:00 AM', '8:30 AM', '9:00 AM', '9:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM', + '12:00 PM', '12:30 PM', '1:00 PM', '1:30 PM', '2:00 PM', '2:30 PM', '3:00 PM', '3:30 PM', + '4:00 PM', '4:30 PM', '5:00 PM', '5:30 PM', '6:00 PM', '6:30 PM', '7:00 PM', '7:30 PM' + ].map((time, timeIndex) => { + const isHour = timeIndex % 2 === 0; + return ( + + {/* Time Label */} + 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} + borderColor={COLORS.grayBorder} + bg="white" + display="flex" + alignItems="center" + > + {time} + + + {/* Days */} + {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => { + const slotKey = `${dayIndex}-${timeIndex}`; + let isAvailable = false; + + if (isEditing) { + // In edit mode, check selectedTimeSlots + isAvailable = selectedTimeSlots.has(slotKey); + } else { + // In view mode, check user.availability + isAvailable = user.availability?.some(block => { + const date = new Date(block.startTime); + const jsDay = date.getDay(); // 0=Sun, 1=Mon... + const gridDay = jsDay === 0 ? 6 : jsDay - 1; + + const hour = date.getHours(); + const minute = date.getMinutes(); + + // Calculate target hour and minute based on timeIndex + // timeIndex 0 -> 8:00, 1 -> 8:30, 2 -> 9:00... + const targetHour = 8 + Math.floor(timeIndex / 2); + const targetMinute = (timeIndex % 2) * 30; + + return gridDay === dayIndex && hour === targetHour && minute === targetMinute; + }) || false; + } + + // Check if this slot is in the drag range + const dragRangeSlots = getDragRangeSlots(); + const isInDragRange = isDragging && dragRangeSlots.has(slotKey); + const dragStartKey = dragStart ? `${dragStart.dayIndex}-${dragStart.timeIndex}` : ''; + const willBeSelected = isInDragRange && dragStartKey && !selectedTimeSlots.has(dragStartKey); + const willBeDeselected = isInDragRange && dragStartKey && selectedTimeSlots.has(dragStartKey); + + // Determine background color + let bgColor = isAvailable ? '#FFF4E6' : 'white'; + if (isDragging && isInDragRange) { + bgColor = willBeSelected ? '#E6F3FF' : willBeDeselected ? '#FFE6E6' : '#FFF4E6'; + } + + return ( + 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} + borderLeft="1px solid" + borderColor={COLORS.grayBorder} + bg={bgColor} + h="30px" + cursor={isEditing ? 'pointer' : 'default'} + onMouseDown={isEditing ? (e) => { + e.preventDefault(); + onMouseDown(dayIndex, timeIndex); + } : undefined} + onMouseEnter={isEditing && isDragging ? () => { + onMouseMove(dayIndex, timeIndex); + } : undefined} + onMouseUp={isEditing ? () => { + onMouseUp(); + } : undefined} + _hover={isEditing && !isDragging ? { bg: isAvailable ? '#FFE8CC' : '#F5F5F5' } : {}} + transition="background-color 0.1s" + userSelect="none" + /> + ); + })} + + ); + })} + + + + {/* Summary Sidebar */} + + Your Availability + + {['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, index) => { + // Filter blocks for this day + // Note: getDay() returns 0 for Sunday, 1 for Monday, etc. + // Our map index 0 is Monday, so we need to match correctly. + // Monday (index 0) -> getDay() 1 + // ... + // Saturday (index 5) -> getDay() 6 + // Sunday (index 6) -> getDay() 0 + const targetDay = index === 6 ? 0 : index + 1; + + const dayBlocks = user.availability?.filter(block => { + const date = new Date(block.startTime); + return date.getDay() === targetDay; + }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + + if (!dayBlocks || dayBlocks.length === 0) { + return null; + } + + // Group contiguous blocks into ranges + const ranges: { start: Date; end: Date }[] = []; + if (dayBlocks.length > 0) { + let currentStart = new Date(dayBlocks[0].startTime); + let currentEnd = new Date(dayBlocks[0].startTime); + currentEnd.setMinutes(currentEnd.getMinutes() + 30); // Each block is 30 mins + + for (let i = 1; i < dayBlocks.length; i++) { + const nextBlockStart = new Date(dayBlocks[i].startTime); + if (nextBlockStart.getTime() === currentEnd.getTime()) { + // Contiguous, extend current range + currentEnd.setMinutes(currentEnd.getMinutes() + 30); + } else { + // Gap found, push current range and start new one + ranges.push({ start: currentStart, end: currentEnd }); + currentStart = nextBlockStart; + currentEnd = new Date(nextBlockStart); + currentEnd.setMinutes(currentEnd.getMinutes() + 30); + } + } + ranges.push({ start: currentStart, end: currentEnd }); + } + + return ( + + {day}: + + {ranges.map((range, i) => { + // Format time: 12:00 PM - 4:00 PM + const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + }; + return ( + + {formatTime(range.start)} - {formatTime(range.end)} + + ); + })} + + + ); + })} + + + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/CancerExperienceSection.tsx b/frontend/src/components/admin/userProfile/CancerExperienceSection.tsx new file mode 100644 index 00000000..ed67e3dd --- /dev/null +++ b/frontend/src/components/admin/userProfile/CancerExperienceSection.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, + HStack, + Badge, + SimpleGrid, + Input, +} from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; +import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; +import { formatDateLong } from '@/utils/dateUtils'; +import { DIAGNOSIS_OPTIONS } from '@/utils/userProfileUtils'; +import { CancerEditData } from '@/types/userProfileTypes'; +import { UserData } from '@/types/userTypes'; + +interface CancerExperienceSectionProps { + userData: UserData | null | undefined; + editingField: string | null; + isSaving: boolean; + editData: CancerEditData; + treatmentOptions: string[]; + experienceOptions: string[]; + onEditDataChange: (data: CancerEditData) => void; + onStartEdit: (fieldName: string) => void; + onCancelEdit: () => void; + onSave: (fieldName: string) => void; +} + +export function CancerExperienceSection({ + userData, + editingField, + isSaving, + editData, + treatmentOptions, + experienceOptions, + onEditDataChange, + onStartEdit, + onCancelEdit, + onSave, +}: CancerExperienceSectionProps) { + if (userData?.hasBloodCancer !== 'yes') return null; + + return ( + + + Blood cancer experience information + + + + {/* Diagnosis */} + + + Diagnosis + {editingField === 'diagnosis' ? ( + + + + + ) : ( + + )} + + {editingField === 'diagnosis' ? ( + + onEditDataChange({ ...editData, diagnosis: value })} + placeholder="Select diagnosis" + allowClear={false} + /> + + ) : ( + + {userData?.diagnosis ? ( + + {userData.diagnosis} + + ) : ( + N/A + )} + + )} + + + {/* Date of Diagnosis */} + + + Date of Diagnosis + {editingField === 'dateOfDiagnosis' ? ( + + + + + ) : ( + + )} + + {editingField === 'dateOfDiagnosis' ? ( + onEditDataChange({ ...editData, dateOfDiagnosis: e.target.value })} + fontSize="16px" + /> + ) : ( + + {userData?.dateOfDiagnosis ? formatDateLong(userData.dateOfDiagnosis) : 'N/A'} + + )} + + + {/* Treatments */} + + + Treatments + {editingField === 'treatments' ? ( + + + + + ) : ( + + )} + + {editingField === 'treatments' ? ( + + onEditDataChange({ ...editData, treatments: values })} + placeholder="Select treatments" + /> + + ) : ( + + {userData?.treatments?.length ? ( + userData.treatments.map(t => ( + + {t.name} + + )) + ) : ( + None listed + )} + + )} + + + {/* Experiences */} + + + Experiences + {editingField === 'experiences' ? ( + + + + + ) : ( + + )} + + {editingField === 'experiences' ? ( + + onEditDataChange({ ...editData, experiences: values })} + placeholder="Select experiences" + /> + + ) : ( + + {userData?.experiences?.length ? ( + userData.experiences.map(e => ( + + {e.name} + + )) + ) : ( + None listed + )} + + )} + + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/LovedOneSection.tsx b/frontend/src/components/admin/userProfile/LovedOneSection.tsx new file mode 100644 index 00000000..bf51e04e --- /dev/null +++ b/frontend/src/components/admin/userProfile/LovedOneSection.tsx @@ -0,0 +1,408 @@ +import React from 'react'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, + HStack, + Badge, + SimpleGrid, + Input, +} from '@chakra-ui/react'; +import { FiHeart } from 'react-icons/fi'; +import { COLORS } from '@/constants/colors'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; +import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; +import { formatDateLong } from '@/utils/dateUtils'; +import { DIAGNOSIS_OPTIONS } from '@/utils/userProfileUtils'; +import { LovedOneEditData } from '@/types/userProfileTypes'; +import { UserData } from '@/types/userTypes'; + +interface LovedOneSectionProps { + userData: UserData | null | undefined; + editingField: string | null; + isSaving: boolean; + editData: LovedOneEditData; + treatmentOptions: string[]; + experienceOptions: string[]; + onEditDataChange: (data: LovedOneEditData) => void; + onStartEdit: (fieldName: string, isLovedOne: boolean) => void; + onCancelEdit: () => void; + onSave: (fieldName: string, isLovedOne: boolean) => void; +} + +export function LovedOneSection({ + userData, + editingField, + isSaving, + editData, + treatmentOptions, + experienceOptions, + onEditDataChange, + onStartEdit, + onCancelEdit, + onSave, +}: LovedOneSectionProps) { + if (userData?.caringForSomeone !== 'yes') return null; + + const hasOwnCancer = userData?.hasBloodCancer === 'yes'; + + return ( + <> + {/* Divider between user's own info and loved one info */} + {hasOwnCancer && ( + + )} + + + {!hasOwnCancer && ( + + Blood cancer experience information + + )} + {hasOwnCancer && ( + + Loved One's Blood cancer experience information + + )} + + {/* Loved One's Diagnosis */} + + + + + + Loved One's Diagnosis + + + {editingField === 'lovedOneDiagnosis' ? ( + + + + + ) : ( + + )} + + {editingField === 'lovedOneDiagnosis' ? ( + + onEditDataChange({ ...editData, diagnosis: value })} + placeholder="Select diagnosis" + allowClear={false} + /> + + ) : ( + + {userData?.lovedOneDiagnosis ? ( + + {userData.lovedOneDiagnosis} + + ) : ( + N/A + )} + + )} + + + {/* Loved One's Date of Diagnosis */} + + + + + + Loved One's Date of Diagnosis + + + {editingField === 'lovedOneDateOfDiagnosis' ? ( + + + + + ) : ( + + )} + + {editingField === 'lovedOneDateOfDiagnosis' ? ( + onEditDataChange({ ...editData, dateOfDiagnosis: e.target.value })} + fontSize="16px" + /> + ) : ( + + {userData?.lovedOneDateOfDiagnosis ? formatDateLong(userData.lovedOneDateOfDiagnosis) : 'N/A'} + + )} + + + {/* Treatments Loved One Has Done */} + + + + + + Treatments Loved One Has Done + + + {editingField === 'lovedOneTreatments' ? ( + + + + + ) : ( + + )} + + {editingField === 'lovedOneTreatments' ? ( + + onEditDataChange({ ...editData, treatments: values })} + placeholder="Select treatments" + /> + + ) : ( + + {userData?.lovedOneTreatments?.length ? ( + userData.lovedOneTreatments.map(t => ( + + {t.name} + + )) + ) : ( + None listed + )} + + )} + + + {/* Experiences Loved One Had */} + + + + + + Experiences Loved One Had + + + {editingField === 'lovedOneExperiences' ? ( + + + + + ) : ( + + )} + + {editingField === 'lovedOneExperiences' ? ( + + onEditDataChange({ ...editData, experiences: values })} + placeholder="Select experiences" + /> + + ) : ( + + {userData?.lovedOneExperiences?.length ? ( + userData.lovedOneExperiences.map(e => ( + + {e.name} + + )) + ) : ( + None listed + )} + + )} + + + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/ProfileContent.tsx b/frontend/src/components/admin/userProfile/ProfileContent.tsx new file mode 100644 index 00000000..b096f7ac --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileContent.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, +} from '@chakra-ui/react'; +import { UserRole } from '@/types/authTypes'; +import { COLORS } from '@/constants/colors'; +import { UserResponse } from '@/types/userTypes'; +import { UserData, VolunteerData } from '@/types/userTypes'; +import { CancerExperienceSection } from './CancerExperienceSection'; +import { LovedOneSection } from './LovedOneSection'; +import { AvailabilitySection } from './AvailabilitySection'; +import { CancerEditData, LovedOneEditData } from '@/types/userProfileTypes'; + +interface ProfileContentProps { + user: UserResponse; + role: UserRole; + userData: UserData | null | undefined; + volunteerData: VolunteerData | null | undefined; + editingField: string | null; + isSaving: boolean; + cancerEditData: CancerEditData; + lovedOneEditData: LovedOneEditData; + treatmentOptions: string[]; + experienceOptions: string[]; + isEditingAvailability: boolean; + selectedTimeSlots: Set; + isDragging: boolean; + dragStart: { dayIndex: number; timeIndex: number } | null; + getDragRangeSlots: () => Set; + isSavingAvailability?: boolean; + onCancerEditDataChange: (data: CancerEditData) => void; + onLovedOneEditDataChange: (data: LovedOneEditData) => void; + onStartEditField: (fieldName: string, isLovedOne?: boolean) => void; + onCancelEditField: () => void; + onSaveField: (fieldName: string, isLovedOne?: boolean) => void; + onStartEditAvailability: () => void; + onCancelEditAvailability: () => void; + onSaveAvailability: () => void; + onMouseDown: (dayIndex: number, timeIndex: number) => void; + onMouseMove: (dayIndex: number, timeIndex: number) => void; + onMouseUp: () => void; +} + +export function ProfileContent({ + user, + role, + userData, + volunteerData, + editingField, + isSaving, + cancerEditData, + lovedOneEditData, + treatmentOptions, + experienceOptions, + isEditingAvailability, + selectedTimeSlots, + isDragging, + dragStart, + getDragRangeSlots, + isSavingAvailability = false, + onCancerEditDataChange, + onLovedOneEditDataChange, + onStartEditField, + onCancelEditField, + onSaveField, + onStartEditAvailability, + onCancelEditAvailability, + onSaveAvailability, + onMouseDown, + onMouseMove, + onMouseUp, +}: ProfileContentProps) { + return ( + + + {/* Header Section */} + + + + {user.firstName} {user.lastName} + + + {role} + + + + + + + + + {/* Overview - Only for Volunteers */} + {role === UserRole.VOLUNTEER && ( + <> + + + Overview + + + {volunteerData?.experience || userData?.additionalInfo || "No overview provided."} + + + + + + )} + + {/* Detailed Info */} + + {/* User's Own Cancer Experience */} + + + {/* Loved One Info */} + + + {/* Availability - Only for Volunteers */} + {role === UserRole.VOLUNTEER && ( + + )} + + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/ProfileNavigation.tsx b/frontend/src/components/admin/userProfile/ProfileNavigation.tsx new file mode 100644 index 00000000..5201a0b0 --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileNavigation.tsx @@ -0,0 +1,85 @@ +import { Box, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { FiUser, FiFileText, FiUsers } from 'react-icons/fi'; +import { COLORS } from '@/constants/colors'; + +interface ProfileNavigationProps { + activeTab: string; + onTabChange: (tab: string) => void; +} + +export function ProfileNavigation({ activeTab, onTabChange }: ProfileNavigationProps) { + const isProfileActive = activeTab === 'profile' || !activeTab; + const isFormsActive = activeTab === 'forms'; + const isMatchesActive = activeTab === 'matches'; + + return ( + + + + + + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/ProfileSummary.tsx b/frontend/src/components/admin/userProfile/ProfileSummary.tsx new file mode 100644 index 00000000..cdb53d81 --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileSummary.tsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { + Box, + Flex, + Heading, + Text, + Button, + VStack, + HStack, + IconButton, + Input, +} from '@chakra-ui/react'; +import { FiEdit2, FiHeart } from 'react-icons/fi'; +import { COLORS } from '@/constants/colors'; +import { formatArray, capitalizeWords } from '@/utils/userProfileUtils'; +import { formatDateLong } from '@/utils/dateUtils'; +import { ProfileEditData } from '@/types/userProfileTypes'; +import { UserData } from '@/types/userTypes'; + +interface ProfileSummaryProps { + userData: UserData | null | undefined; + userEmail?: string; + isEditing: boolean; + isSaving: boolean; + editData: ProfileEditData; + onEditDataChange: (data: ProfileEditData) => void; + onStartEdit: () => void; + onSave: () => void; + onCancel: () => void; +} + +export function ProfileSummary({ + userData, + userEmail, + isEditing, + isSaving, + editData, + onEditDataChange, + onStartEdit, + onSave, + onCancel, +}: ProfileSummaryProps) { + return ( + + + Profile Summary + {!isEditing ? ( + + + + ) : ( + + + + + )} + + + {/* Name */} + + Name + {isEditing ? ( + + onEditDataChange({ ...editData, firstName: e.target.value })} + placeholder="First Name" + fontSize="sm" + /> + onEditDataChange({ ...editData, lastName: e.target.value })} + placeholder="Last Name" + fontSize="sm" + /> + + ) : ( + + {userData?.firstName || ''} {userData?.lastName || ''} + + )} + + {/* Email Address - Read only */} + + Email Address + {userEmail || userData?.email || 'N/A'} + + {/* Birthday */} + + Birthday + {isEditing ? ( + onEditDataChange({ ...editData, dateOfBirth: e.target.value })} + fontSize="sm" + /> + ) : ( + + {userData?.dateOfBirth ? formatDateLong(userData.dateOfBirth) : 'N/A'} + + )} + + {/* Phone Number */} + + Phone Number + {isEditing ? ( + onEditDataChange({ ...editData, phone: e.target.value })} + placeholder="Phone Number" + fontSize="sm" + /> + ) : ( + {userData?.phone || 'N/A'} + )} + + {/* Gender */} + + Gender + {isEditing ? ( + onEditDataChange({ ...editData, genderIdentity: e.target.value })} + placeholder="Gender" + fontSize="sm" + /> + ) : ( + {userData?.genderIdentity || 'N/A'} + )} + + {/* Pronouns */} + + Pronouns + {isEditing ? ( + onEditDataChange({ + ...editData, + pronouns: e.target.value.split(',').map(p => p.trim()).filter(Boolean) + })} + placeholder="Pronouns (comma-separated)" + fontSize="sm" + /> + ) : ( + {formatArray(userData?.pronouns)} + )} + + {/* Time Zone */} + + Time Zone + {isEditing ? ( + onEditDataChange({ ...editData, timezone: e.target.value })} + placeholder="Time Zone" + fontSize="sm" + /> + ) : ( + {userData?.timezone || 'N/A'} + )} + + {/* Ethnic or Cultural Group */} + + Ethnic or Cultural Group + {isEditing ? ( + onEditDataChange({ + ...editData, + ethnicGroup: e.target.value.split(',').map(g => g.trim()).filter(Boolean) + })} + placeholder="Ethnic or Cultural Group (comma-separated)" + fontSize="sm" + /> + ) : ( + {formatArray(userData?.ethnicGroup)} + )} + + {/* Preferred Language */} + + Preferred Language + N/A + + {/* Marital Status */} + + Marital Status + {isEditing ? ( + onEditDataChange({ ...editData, maritalStatus: e.target.value })} + placeholder="Marital Status" + fontSize="sm" + /> + ) : ( + {capitalizeWords(userData?.maritalStatus)} + )} + + {/* Parental Status */} + + Parental Status + {isEditing ? ( + onEditDataChange({ ...editData, hasKids: e.target.value })} + placeholder="Parental Status" + fontSize="sm" + /> + ) : ( + {capitalizeWords(userData?.hasKids)} + )} + + + {/* Divider before Loved One fields */} + {userData?.caringForSomeone === 'yes' && ( + <> + + + + + LO's Gender + + {isEditing ? ( + onEditDataChange({ ...editData, lovedOneGenderIdentity: e.target.value })} + placeholder="Loved One's Gender" + fontSize="sm" + ml={4} + /> + ) : ( + + {userData?.lovedOneGenderIdentity || 'N/A'} + + )} + + + + + LO's Age + + {isEditing ? ( + onEditDataChange({ ...editData, lovedOneAge: e.target.value })} + placeholder="Loved One's Age" + fontSize="sm" + ml={4} + /> + ) : ( + + {userData?.lovedOneAge || 'N/A'} + + )} + + + )} + + + ); +} + diff --git a/frontend/src/components/admin/userProfile/SuccessMessage.tsx b/frontend/src/components/admin/userProfile/SuccessMessage.tsx new file mode 100644 index 00000000..02c49567 --- /dev/null +++ b/frontend/src/components/admin/userProfile/SuccessMessage.tsx @@ -0,0 +1,31 @@ +import { Box, Text } from '@chakra-ui/react'; +import { SaveMessage } from '@/types/userProfileTypes'; + +interface SuccessMessageProps { + message: SaveMessage | null; +} + +export function SuccessMessage({ message }: SuccessMessageProps) { + if (!message) return null; + + return ( + + + {message.text} + + + ); +} + diff --git a/frontend/src/components/ui/single-select-dropdown.tsx b/frontend/src/components/ui/single-select-dropdown.tsx index 08965bf1..c43494e0 100644 --- a/frontend/src/components/ui/single-select-dropdown.tsx +++ b/frontend/src/components/ui/single-select-dropdown.tsx @@ -9,6 +9,7 @@ interface SingleSelectDropdownProps { placeholder: string; error?: boolean; onOpenChange?: (isOpen: boolean) => void; + allowClear?: boolean; // If false, prevents clearing the selection } export const SingleSelectDropdown: React.FC = ({ @@ -18,6 +19,7 @@ export const SingleSelectDropdown: React.FC = ({ placeholder, error, onOpenChange, + allowClear = true, }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -110,27 +112,29 @@ export const SingleSelectDropdown: React.FC = ({ > {selectedValue} - + {allowClear && ( + + )} ) : ( diff --git a/frontend/src/hooks/useAvailabilityEditing.ts b/frontend/src/hooks/useAvailabilityEditing.ts new file mode 100644 index 00000000..e58a83ca --- /dev/null +++ b/frontend/src/hooks/useAvailabilityEditing.ts @@ -0,0 +1,312 @@ +import { useState, useEffect } from 'react'; +import { getUserById, createAvailability, deleteAvailability, TimeRange } from '@/APIClients/authAPIClient'; +import { UserResponse } from '@/types/userTypes'; +import { SaveMessage } from '@/types/userProfileTypes'; + +interface UseAvailabilityEditingProps { + userId: string | string[] | undefined; + user: UserResponse | null; + setUser: (user: UserResponse) => void; + setSaveMessage: (message: SaveMessage | null) => void; +} + +export function useAvailabilityEditing({ + userId, + user, + setUser, + setSaveMessage, +}: UseAvailabilityEditingProps) { + const [isSaving, setIsSaving] = useState(false); + const [isEditingAvailability, setIsEditingAvailability] = useState(false); + const [selectedTimeSlots, setSelectedTimeSlots] = useState>(new Set()); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState<{ dayIndex: number; timeIndex: number } | null>(null); + const [dragEnd, setDragEnd] = useState<{ dayIndex: number; timeIndex: number } | null>(null); + + // Handle global mouse up for drag + useEffect(() => { + const handleGlobalMouseUp = () => { + if (isDragging && dragStart && dragEnd) { + const minDay = Math.min(dragStart.dayIndex, dragEnd.dayIndex); + const maxDay = Math.max(dragStart.dayIndex, dragEnd.dayIndex); + const minTime = Math.min(dragStart.timeIndex, dragEnd.timeIndex); + const maxTime = Math.max(dragStart.timeIndex, dragEnd.timeIndex); + + const newSlots = new Set(selectedTimeSlots); + const slotsInRange: string[] = []; + + for (let day = minDay; day <= maxDay; day++) { + for (let time = minTime; time <= maxTime; time++) { + slotsInRange.push(`${day}-${time}`); + } + } + + const startKey = `${dragStart.dayIndex}-${dragStart.timeIndex}`; + const isRemoving = selectedTimeSlots.has(startKey); + + slotsInRange.forEach(key => { + if (isRemoving) { + newSlots.delete(key); + } else { + newSlots.add(key); + } + }); + + setSelectedTimeSlots(newSlots); + setIsDragging(false); + setDragStart(null); + setDragEnd(null); + } + }; + + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => { + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [isDragging, dragStart, dragEnd, selectedTimeSlots]); + + const handleStartEditAvailability = () => { + const slots = new Set(); + if (user?.availability) { + user.availability.forEach(block => { + const date = new Date(block.startTime); + const jsDay = date.getDay(); + const gridDay = jsDay === 0 ? 6 : jsDay - 1; + + const hour = date.getHours(); + const minute = date.getMinutes(); + const timeIndex = (hour - 8) * 2 + (minute === 30 ? 1 : 0); + + if (timeIndex >= 0 && timeIndex < 48) { + slots.add(`${gridDay}-${timeIndex}`); + } + }); + } + setSelectedTimeSlots(slots); + setIsEditingAvailability(true); + }; + + const handleMouseDown = (dayIndex: number, timeIndex: number) => { + if (!isEditingAvailability) return; + setIsDragging(true); + setDragStart({ dayIndex, timeIndex }); + setDragEnd({ dayIndex, timeIndex }); + }; + + const handleMouseMove = (dayIndex: number, timeIndex: number) => { + if (!isDragging || !isEditingAvailability) return; + setDragEnd({ dayIndex, timeIndex }); + }; + + const handleMouseUp = () => { + if (!isDragging || !dragStart || !dragEnd) { + setIsDragging(false); + setDragStart(null); + setDragEnd(null); + return; + } + + const minDay = Math.min(dragStart.dayIndex, dragEnd.dayIndex); + const maxDay = Math.max(dragStart.dayIndex, dragEnd.dayIndex); + const minTime = Math.min(dragStart.timeIndex, dragEnd.timeIndex); + const maxTime = Math.max(dragStart.timeIndex, dragEnd.timeIndex); + + const newSlots = new Set(selectedTimeSlots); + const slotsInRange: string[] = []; + + for (let day = minDay; day <= maxDay; day++) { + for (let time = minTime; time <= maxTime; time++) { + slotsInRange.push(`${day}-${time}`); + } + } + + const startKey = `${dragStart.dayIndex}-${dragStart.timeIndex}`; + const isRemoving = selectedTimeSlots.has(startKey); + + slotsInRange.forEach(key => { + if (isRemoving) { + newSlots.delete(key); + } else { + newSlots.add(key); + } + }); + + setSelectedTimeSlots(newSlots); + setIsDragging(false); + setDragStart(null); + setDragEnd(null); + }; + + const getDragRangeSlots = (): Set => { + if (!dragStart || !dragEnd) return new Set(); + + const minDay = Math.min(dragStart.dayIndex, dragEnd.dayIndex); + const maxDay = Math.max(dragStart.dayIndex, dragEnd.dayIndex); + const minTime = Math.min(dragStart.timeIndex, dragEnd.timeIndex); + const maxTime = Math.max(dragStart.timeIndex, dragEnd.timeIndex); + + const rangeSlots = new Set(); + for (let day = minDay; day <= maxDay; day++) { + for (let time = minTime; time <= maxTime; time++) { + rangeSlots.add(`${day}-${time}`); + } + } + return rangeSlots; + }; + + const convertSlotsToTimeRanges = (): TimeRange[] => { + const referenceMonday = new Date('2000-01-03T00:00:00'); + const ranges: TimeRange[] = []; + const slots = Array.from(selectedTimeSlots).map(key => { + const [dayIndex, timeIndex] = key.split('-').map(Number); + return { dayIndex, timeIndex }; + }).sort((a, b) => { + if (a.dayIndex !== b.dayIndex) return a.dayIndex - b.dayIndex; + return a.timeIndex - b.timeIndex; + }); + + interface TimeRangeSlot { + dayIndex: number; + startTimeIndex: number; + endTimeIndex: number; + } + let currentRange: TimeRangeSlot | null = null; + + slots.forEach(({ dayIndex, timeIndex }) => { + if (!currentRange || currentRange.dayIndex !== dayIndex || currentRange.endTimeIndex !== timeIndex - 1) { + if (currentRange) { + const startDate = new Date(referenceMonday); + startDate.setDate(referenceMonday.getDate() + currentRange.dayIndex); + startDate.setHours(8 + Math.floor(currentRange.startTimeIndex / 2), (currentRange.startTimeIndex % 2) * 30, 0, 0); + + const endDate = new Date(startDate); + endDate.setMinutes(endDate.getMinutes() + 30 * (currentRange.endTimeIndex - currentRange.startTimeIndex + 1)); + + ranges.push({ + startTime: startDate.toISOString(), + endTime: endDate.toISOString(), + }); + } + currentRange = { dayIndex, startTimeIndex: timeIndex, endTimeIndex: timeIndex }; + } else { + if (currentRange) { + currentRange.endTimeIndex = timeIndex; + } + } + }); + + if (currentRange !== null) { + const range: TimeRangeSlot = currentRange; + const startDate = new Date(referenceMonday); + startDate.setDate(referenceMonday.getDate() + range.dayIndex); + startDate.setHours(8 + Math.floor(range.startTimeIndex / 2), (range.startTimeIndex % 2) * 30, 0, 0); + + const endDate = new Date(startDate); + endDate.setMinutes(endDate.getMinutes() + 30 * (range.endTimeIndex - range.startTimeIndex + 1)); + + ranges.push({ + startTime: startDate.toISOString(), + endTime: endDate.toISOString(), + }); + } + + return ranges; + }; + + const handleSaveAvailability = async () => { + if (!userId || !user) return; + + setIsSaving(true); + try { + const existingRanges: TimeRange[] = []; + if (user.availability && user.availability.length > 0) { + const sortedBlocks = [...user.availability].sort((a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + let currentStart: Date | null = null; + let currentEnd: Date | null = null; + + sortedBlocks.forEach(block => { + const blockStart = new Date(block.startTime); + const blockEnd = new Date(blockStart); + blockEnd.setMinutes(blockEnd.getMinutes() + 30); + + if (!currentStart) { + currentStart = blockStart; + currentEnd = blockEnd; + } else if (currentEnd && blockStart.getTime() === currentEnd.getTime()) { + currentEnd = blockEnd; + } else { + if (currentStart && currentEnd) { + existingRanges.push({ + startTime: currentStart.toISOString(), + endTime: currentEnd.toISOString(), + }); + } + currentStart = blockStart; + currentEnd = blockEnd; + } + }); + + if (currentStart !== null && currentEnd !== null) { + const start: Date = currentStart; + const end: Date = currentEnd; + existingRanges.push({ + startTime: start.toISOString(), + endTime: end.toISOString(), + }); + } + } + + if (existingRanges.length > 0) { + await deleteAvailability({ + userId: userId as string, + delete: existingRanges, + }); + } + + const newRanges = convertSlotsToTimeRanges(); + if (newRanges.length > 0) { + await createAvailability({ + userId: userId as string, + availableTimes: newRanges, + }); + } + + const updatedUser = await getUserById(userId as string); + setUser(updatedUser); + setIsEditingAvailability(false); + setSelectedTimeSlots(new Set()); + setSaveMessage({ type: 'success', text: 'Availability updated successfully' }); + setTimeout(() => setSaveMessage(null), 3000); + } catch (error) { + console.error('Failed to update availability:', error); + setSaveMessage({ type: 'error', text: 'Failed to update availability. Please try again.' }); + setTimeout(() => setSaveMessage(null), 3000); + } finally { + setIsSaving(false); + } + }; + + const handleCancelEditAvailability = () => { + setIsEditingAvailability(false); + setSelectedTimeSlots(new Set()); + }; + + return { + isEditingAvailability, + selectedTimeSlots, + isDragging, + dragStart, + isSaving, + getDragRangeSlots, + handleStartEditAvailability, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleSaveAvailability, + handleCancelEditAvailability, + }; +} + diff --git a/frontend/src/hooks/useIntakeOptions.ts b/frontend/src/hooks/useIntakeOptions.ts new file mode 100644 index 00000000..12f0852f --- /dev/null +++ b/frontend/src/hooks/useIntakeOptions.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from 'react'; +import baseAPIClient from '@/APIClients/baseAPIClient'; + +export function useIntakeOptions() { + const [treatmentOptions, setTreatmentOptions] = useState([]); + const [experienceOptions, setExperienceOptions] = useState([]); + + useEffect(() => { + const fetchOptions = async () => { + try { + const response = await baseAPIClient.get<{ + treatments: Array<{ id: number; name: string }>; + experiences: Array<{ id: number; name: string }> + }>('/intake/options?target=both'); + setTreatmentOptions(response.data.treatments?.map(t => t.name) || []); + setExperienceOptions(response.data.experiences?.map(e => e.name) || []); + } catch (error) { + console.error('Failed to fetch options:', error); + } + }; + fetchOptions(); + }, []); + + return { treatmentOptions, experienceOptions }; +} + diff --git a/frontend/src/hooks/useProfileEditing.ts b/frontend/src/hooks/useProfileEditing.ts new file mode 100644 index 00000000..24772112 --- /dev/null +++ b/frontend/src/hooks/useProfileEditing.ts @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { updateUserData } from '@/APIClients/authAPIClient'; +import { UserResponse } from '@/types/userTypes'; +import { ProfileEditData, CancerEditData, LovedOneEditData, SaveMessage } from '@/types/userProfileTypes'; + +interface UseProfileEditingProps { + userId: string | string[] | undefined; + user: UserResponse | null; + setUser: (user: UserResponse) => void; + setSaveMessage: (message: SaveMessage | null) => void; +} + +export function useProfileEditing({ userId, user, setUser, setSaveMessage }: UseProfileEditingProps) { + const [isEditingProfileSummary, setIsEditingProfileSummary] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [profileEditData, setProfileEditData] = useState({}); + const [editingField, setEditingField] = useState(null); + const [cancerEditData, setCancerEditData] = useState({}); + const [lovedOneEditData, setLovedOneEditData] = useState({}); + + const userData = user?.userData; + + const handleStartEditProfileSummary = () => { + if (userData) { + setProfileEditData({ + firstName: userData.firstName || '', + lastName: userData.lastName || '', + dateOfBirth: userData.dateOfBirth || '', + phone: userData.phone || '', + genderIdentity: userData.genderIdentity || '', + pronouns: userData.pronouns || [], + timezone: userData.timezone || '', + ethnicGroup: userData.ethnicGroup || [], + maritalStatus: userData.maritalStatus || '', + hasKids: userData.hasKids || '', + lovedOneGenderIdentity: userData.lovedOneGenderIdentity || '', + lovedOneAge: userData.lovedOneAge || '', + }); + } + setIsEditingProfileSummary(true); + }; + + const handleSaveProfileSummary = async () => { + if (!userId || !user) return; + + setIsSaving(true); + try { + const updatedUser = await updateUserData(userId as string, { + firstName: profileEditData.firstName, + lastName: profileEditData.lastName, + dateOfBirth: profileEditData.dateOfBirth, + phone: profileEditData.phone, + genderIdentity: profileEditData.genderIdentity, + pronouns: profileEditData.pronouns, + timezone: profileEditData.timezone, + ethnicGroup: profileEditData.ethnicGroup, + maritalStatus: profileEditData.maritalStatus, + hasKids: profileEditData.hasKids, + lovedOneGenderIdentity: profileEditData.lovedOneGenderIdentity, + lovedOneAge: profileEditData.lovedOneAge, + }); + + setUser(updatedUser); + setIsEditingProfileSummary(false); + setSaveMessage({ type: 'success', text: 'Profile summary updated successfully' }); + setTimeout(() => setSaveMessage(null), 3000); + } catch (error) { + console.error('Failed to update profile:', error); + setSaveMessage({ type: 'error', text: 'Failed to update profile. Please try again.' }); + setTimeout(() => setSaveMessage(null), 3000); + } finally { + setIsSaving(false); + } + }; + + const handleCancelEditProfileSummary = () => { + setIsEditingProfileSummary(false); + setProfileEditData({}); + }; + + const handleStartEditField = (fieldName: string, isLovedOne: boolean = false) => { + if (isLovedOne) { + const currentData = userData; + setLovedOneEditData({ + diagnosis: currentData?.lovedOneDiagnosis || '', + dateOfDiagnosis: currentData?.lovedOneDateOfDiagnosis || '', + treatments: currentData?.lovedOneTreatments?.map(t => t.name) || [], + experiences: currentData?.lovedOneExperiences?.map(e => e.name) || [], + }); + } else { + const currentData = userData; + setCancerEditData({ + diagnosis: currentData?.diagnosis || '', + dateOfDiagnosis: currentData?.dateOfDiagnosis || '', + treatments: currentData?.treatments?.map(t => t.name) || [], + experiences: currentData?.experiences?.map(e => e.name) || [], + additionalInfo: currentData?.additionalInfo || '', + }); + } + setEditingField(fieldName); + }; + + const handleCancelEditField = () => { + setEditingField(null); + setCancerEditData({}); + setLovedOneEditData({}); + }; + + const handleSaveField = async (fieldName: string, isLovedOne: boolean = false) => { + if (!userId || !user) return; + + setIsSaving(true); + try { + const updateData: Record = {}; + + if (isLovedOne) { + if (fieldName === 'diagnosis' || fieldName === 'lovedOneDiagnosis') updateData.lovedOneDiagnosis = lovedOneEditData.diagnosis; + if (fieldName === 'dateOfDiagnosis' || fieldName === 'lovedOneDateOfDiagnosis') { + updateData.lovedOneDateOfDiagnosis = lovedOneEditData.dateOfDiagnosis && lovedOneEditData.dateOfDiagnosis.trim() !== '' + ? lovedOneEditData.dateOfDiagnosis + : null; + } + if (fieldName === 'treatments' || fieldName === 'lovedOneTreatments') updateData.lovedOneTreatments = lovedOneEditData.treatments; + if (fieldName === 'experiences' || fieldName === 'lovedOneExperiences') updateData.lovedOneExperiences = lovedOneEditData.experiences; + } else { + if (fieldName === 'diagnosis') updateData.diagnosis = cancerEditData.diagnosis; + if (fieldName === 'dateOfDiagnosis') { + updateData.dateOfDiagnosis = cancerEditData.dateOfDiagnosis && cancerEditData.dateOfDiagnosis.trim() !== '' + ? cancerEditData.dateOfDiagnosis + : null; + } + if (fieldName === 'treatments') updateData.treatments = cancerEditData.treatments; + if (fieldName === 'experiences') updateData.experiences = cancerEditData.experiences; + if (fieldName === 'additionalInfo') updateData.additionalInfo = cancerEditData.additionalInfo; + } + + const updatedUser = await updateUserData(userId as string, updateData); + setUser(updatedUser); + setEditingField(null); + setCancerEditData({}); + setLovedOneEditData({}); + setSaveMessage({ type: 'success', text: 'Field updated successfully' }); + setTimeout(() => setSaveMessage(null), 3000); + } catch (error) { + console.error('Failed to update field:', error); + setSaveMessage({ type: 'error', text: 'Failed to update field. Please try again.' }); + setTimeout(() => setSaveMessage(null), 3000); + } finally { + setIsSaving(false); + } + }; + + return { + isEditingProfileSummary, + isSaving, + profileEditData, + setProfileEditData, + editingField, + cancerEditData, + setCancerEditData, + lovedOneEditData, + setLovedOneEditData, + handleStartEditProfileSummary, + handleSaveProfileSummary, + handleCancelEditProfileSummary, + handleStartEditField, + handleCancelEditField, + handleSaveField, + }; +} + diff --git a/frontend/src/hooks/useUserProfile.ts b/frontend/src/hooks/useUserProfile.ts new file mode 100644 index 00000000..2c41a8b6 --- /dev/null +++ b/frontend/src/hooks/useUserProfile.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react'; +import { getUserById } from '@/APIClients/authAPIClient'; +import { UserResponse } from '@/types/userTypes'; + +export function useUserProfile(userId: string | string[] | undefined) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (userId) { + const fetchUser = async () => { + try { + const userData = await getUserById(userId as string); + setUser(userData); + } catch (error) { + console.error('Failed to fetch user:', error); + } finally { + setLoading(false); + } + }; + fetchUser(); + } + }, [userId]); + + return { user, loading, setUser }; +} + diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 62a506e0..6952f6a4 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -16,6 +16,7 @@ import { VStack, } from '@chakra-ui/react'; import { useState } from 'react'; +import { useRouter } from 'next/router'; import { FiSearch, FiMenu, FiMail, FiChevronDown, FiChevronUp } from 'react-icons/fi'; import { TbSelector } from 'react-icons/tb'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; @@ -123,6 +124,7 @@ const getStatusColor = (step: string): { bg: string; color: string } => { }; export default function Directory() { + const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [selectedUsers, setSelectedUsers] = useState>(new Set()); const [sortBy, setSortBy] = useState<'nameAsc' | 'nameDsc' | 'statusAsc' | 'statusDsc'>( @@ -436,7 +438,15 @@ export default function Directory() { lineHeight="1.362em" color="#495D6C" > - {displayName} + router.push(`/admin/users/${user.id}`)} + > + {displayName} + { - if (!arr || arr.length === 0) return 'N/A'; - return arr.join(', '); -}; +import { useUserProfile } from '@/hooks/useUserProfile'; +import { useIntakeOptions } from '@/hooks/useIntakeOptions'; +import { useProfileEditing } from '@/hooks/useProfileEditing'; +import { useAvailabilityEditing } from '@/hooks/useAvailabilityEditing'; +import { ProfileNavigation } from '@/components/admin/userProfile/ProfileNavigation'; +import { SuccessMessage } from '@/components/admin/userProfile/SuccessMessage'; +import { ProfileSummary } from '@/components/admin/userProfile/ProfileSummary'; +import { ProfileContent } from '@/components/admin/userProfile/ProfileContent'; +import { SaveMessage } from '@/types/userProfileTypes'; export default function AdminUserProfile() { const router = useRouter(); const { id } = router.query; - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (id) { - const fetchUser = async () => { - try { - const userData = await getUserById(id as string); - setUser(userData); - } catch (error) { - console.error('Failed to fetch user:', error); - } finally { - setLoading(false); - } - }; - fetchUser(); - } - }, [id]); + const [saveMessage, setSaveMessage] = useState(null); + + // Custom hooks + const { user, loading, setUser } = useUserProfile(id); + const { treatmentOptions, experienceOptions } = useIntakeOptions(); + const { + isEditingProfileSummary, + isSaving, + profileEditData, + setProfileEditData, + editingField, + cancerEditData, + setCancerEditData, + lovedOneEditData, + setLovedOneEditData, + handleStartEditProfileSummary, + handleSaveProfileSummary, + handleCancelEditProfileSummary, + handleStartEditField, + handleCancelEditField, + handleSaveField, + } = useProfileEditing({ + userId: id, + user, + setUser, + setSaveMessage, + }); + + const { + isEditingAvailability, + selectedTimeSlots, + isDragging, + dragStart, + isSaving: isSavingAvailability, + getDragRangeSlots, + handleStartEditAvailability, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleSaveAvailability, + handleCancelEditAvailability, + } = useAvailabilityEditing({ + userId: id, + user, + setUser, + setSaveMessage, + }); if (loading) { return ( - + @@ -66,7 +84,7 @@ export default function AdminUserProfile() { if (!user) { return ( - + User not found @@ -75,648 +93,93 @@ export default function AdminUserProfile() { ); } + const role = roleIdToUserRole(user.roleId); const userData = user.userData; const volunteerData = user.volunteerData; // Determine active tab based on route or query param const activeTab = router.query.tab as string || 'profile'; - const isProfileActive = activeTab === 'profile' || !router.query.tab; - const isFormsActive = activeTab === 'forms'; - const isMatchesActive = activeTab === 'matches'; + + const handleTabChange = (tab: string) => { + router.push({ pathname: router.pathname, query: { ...router.query, tab } }, undefined, { shallow: true }); + }; + + // Don't render if role is null (shouldn't happen, but TypeScript safety) + if (!role) { + return ( + + + + Invalid user role + + + ); + } return ( - + + {/* Left Sidebar */} - - - - - - - + {/* Profile Summary Card */} - - - Profile Summary - - - - - - - Name - {user.firstName} {user.lastName} - - - Email Address - {user.email} - - - Birthday - {userData?.dateOfBirth ? formatDateLong(userData.dateOfBirth) : 'N/A'} - - - Phone Number - {userData?.phone || 'N/A'} - - - Gender - {userData?.genderIdentity || 'N/A'} - - - Pronouns - {formatArray(userData?.pronouns)} - - - Time Zone - {userData?.timezone || 'N/A'} - - - Location - - {[userData?.city, userData?.province].filter(Boolean).join(', ') || 'N/A'} - - - - + {/* Main Content */} - - - {/* Header Section */} - - - - {user.firstName} {user.lastName} - - - {role} - - - - - - - - - {/* Overview - Only for Volunteers */} - {role === UserRole.VOLUNTEER && ( - <> - - - Overview - - - {volunteerData?.experience || userData?.additionalInfo || "No overview provided."} - - - - - - )} - - {/* Detailed Info */} - - {/* User's Own Cancer Experience (only if user has cancer) */} - {userData?.hasBloodCancer === 'yes' && ( - - - Blood cancer experience information - - - - - - Diagnosis - - - - {userData?.diagnosis ? ( - - {userData.diagnosis} - - ) : ( - N/A - )} - - - - - Date of Diagnosis - - - {userData?.dateOfDiagnosis ? formatDateLong(userData.dateOfDiagnosis) : 'N/A'} - - - - - Treatments - - - - {userData?.treatments?.length ? ( - userData.treatments.map(t => {t.name}) - ) : None listed} - - - - - - Experiences - - - - {userData?.experiences?.length ? ( - userData.experiences.map(e => {e.name}) - ) : None listed} - - - - - )} - - {/* Divider between user's own info and loved one info */} - {userData?.hasBloodCancer === 'yes' && userData?.caringForSomeone === 'yes' && ( - - )} - - {/* Loved One Info (only if user is caring for someone) */} - {userData?.caringForSomeone === 'yes' && ( - - {userData?.hasBloodCancer !== 'yes' && ( - - Blood cancer experience information - - )} - {userData?.hasBloodCancer === 'yes' && ( - - Loved One's Blood cancer experience information - - )} - - - - - Loved One's Diagnosis - - - - - - - Loved One's Date of Diagnosis - - - - - - - - - Treatments Loved One Has Done - - - - - {userData?.lovedOneTreatments?.length ? ( - userData.lovedOneTreatments.map(t => {t.name}) - ) : None listed} - - - - - - - - Experiences Loved One Had - - - - - {userData?.lovedOneExperiences?.length ? ( - userData.lovedOneExperiences.map(e => {e.name}) - ) : None listed} - - - - - )} - - {/* Availability - Only for Volunteers */} - {role === UserRole.VOLUNTEER && ( - - - - Availability - - - - - - {/* Grid */} - - - {/* Header Row */} - - EST - - {['Mon', 'Tues', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => ( - - {day} - - ))} - - {/* Time Rows */} - {[ - '8:00 AM', '8:30 AM', '9:00 AM', '9:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM', - '12:00 PM', '12:30 PM', '1:00 PM', '1:30 PM', '2:00 PM', '2:30 PM', '3:00 PM', '3:30 PM', - '4:00 PM', '4:30 PM', '5:00 PM', '5:30 PM', '6:00 PM', '6:30 PM', '7:00 PM', '7:30 PM' - ].map((time, timeIndex) => { - const isHour = timeIndex % 2 === 0; - return ( - - {/* Time Label */} - 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} - borderColor={COLORS.grayBorder} - bg="white" - display="flex" - alignItems="center" - > - {time} - - - {/* Days */} - {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => { - const isAvailable = user.availability?.some(block => { - const date = new Date(block.startTime); - // Adjust for timezone if needed. - // Assuming block.startTime is ISO string and we want to display in local time or specific timezone. - // For now, let's assume the backend returns UTC and we want to display in EST (as per header). - // Ideally we should use a library like date-fns-tz or moment-timezone. - // But for this simplified view, let's just check getDay/getHours/getMinutes. - - const jsDay = date.getDay(); // 0=Sun, 1=Mon... - const gridDay = jsDay === 0 ? 6 : jsDay - 1; - - const hour = date.getHours(); - const minute = date.getMinutes(); - - // Calculate target hour and minute based on timeIndex - // timeIndex 0 -> 8:00, 1 -> 8:30, 2 -> 9:00... - const targetHour = 8 + Math.floor(timeIndex / 2); - const targetMinute = (timeIndex % 2) * 30; - - return gridDay === dayIndex && hour === targetHour && minute === targetMinute; - }); - - return ( - 0 ? (isHour ? "1px solid" : "1px dashed") : "none"} - borderLeft="1px solid" - borderColor={COLORS.grayBorder} - bg={isAvailable ? '#FFF4E6' : 'white'} - h="30px" - /> - ); - })} - - )})} - - - - {/* Summary Sidebar */} - - Your Availability - - {['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, index) => { - // Filter blocks for this day - // Note: getDay() returns 0 for Sunday, 1 for Monday, etc. - // Our map index 0 is Monday, so we need to match correctly. - // Monday (index 0) -> getDay() 1 - // ... - // Saturday (index 5) -> getDay() 6 - // Sunday (index 6) -> getDay() 0 - const targetDay = index === 6 ? 0 : index + 1; - - const dayBlocks = user.availability?.filter(block => { - const date = new Date(block.startTime); - return date.getDay() === targetDay; - }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); - - if (!dayBlocks || dayBlocks.length === 0) { - return null; - } - - // Group contiguous blocks into ranges - const ranges: { start: Date; end: Date }[] = []; - if (dayBlocks.length > 0) { - let currentStart = new Date(dayBlocks[0].startTime); - let currentEnd = new Date(dayBlocks[0].startTime); - currentEnd.setMinutes(currentEnd.getMinutes() + 30); // Each block is 30 mins - - for (let i = 1; i < dayBlocks.length; i++) { - const nextBlockStart = new Date(dayBlocks[i].startTime); - if (nextBlockStart.getTime() === currentEnd.getTime()) { - // Contiguous, extend current range - currentEnd.setMinutes(currentEnd.getMinutes() + 30); - } else { - // Gap found, push current range and start new one - ranges.push({ start: currentStart, end: currentEnd }); - currentStart = nextBlockStart; - currentEnd = new Date(nextBlockStart); - currentEnd.setMinutes(currentEnd.getMinutes() + 30); - } - } - ranges.push({ start: currentStart, end: currentEnd }); - } - - return ( - - {day}: - - {ranges.map((range, i) => { - // Format time: 12:00 PM - 4:00 PM - // Using a simple formatter for now - const formatTime = (date: Date) => { - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); - }; - return ( - - {formatTime(range.start)} - {formatTime(range.end)} - - ); - })} - - - ); - })} - - - - - )} - + {activeTab === 'profile' || !activeTab ? ( + + ) : activeTab === 'forms' ? ( + + Forms content coming soon... - + ) : activeTab === 'matches' ? ( + + Matches content coming soon... + + ) : null} ); diff --git a/frontend/src/types/userProfileTypes.ts b/frontend/src/types/userProfileTypes.ts new file mode 100644 index 00000000..108a6867 --- /dev/null +++ b/frontend/src/types/userProfileTypes.ts @@ -0,0 +1,37 @@ +// Types for user profile editing state + +export interface ProfileEditData { + firstName?: string; + lastName?: string; + dateOfBirth?: string; + phone?: string; + genderIdentity?: string; + pronouns?: string[]; + timezone?: string; + ethnicGroup?: string[]; + maritalStatus?: string; + hasKids?: string; + lovedOneGenderIdentity?: string; + lovedOneAge?: string; +} + +export interface CancerEditData { + diagnosis?: string; + dateOfDiagnosis?: string; + treatments?: string[]; + experiences?: string[]; + additionalInfo?: string; +} + +export interface LovedOneEditData { + diagnosis?: string; + dateOfDiagnosis?: string; + treatments?: string[]; + experiences?: string[]; +} + +export interface SaveMessage { + type: 'success' | 'error'; + text: string; +} + diff --git a/frontend/src/utils/userProfileUtils.ts b/frontend/src/utils/userProfileUtils.ts new file mode 100644 index 00000000..16bf3dfd --- /dev/null +++ b/frontend/src/utils/userProfileUtils.ts @@ -0,0 +1,30 @@ +// Helper to format array of strings (e.g. pronouns) +export const formatArray = (arr?: string[] | null) => { + if (!arr || arr.length === 0) return 'N/A'; + return arr.join(', '); +}; + +// Helper to capitalize first letter of each word +export const capitalizeWords = (str?: string | null) => { + if (!str) return 'N/A'; + return str + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +}; + +// Diagnosis options +export const DIAGNOSIS_OPTIONS: string[] = [ + 'Unknown', + 'Acute Myeloid Leukemia', + 'Acute Lymphoblastic Leukemia', + 'Acute Promyelocytic Leukemia', + 'Mixed Phenotype Leukemia', + 'Chronic Lymphocytic Leukemia/Small Lymphocytic Lymphoma', + 'Chronic Myeloid Leukemia', + 'Hairy Cell Leukemia', + 'Myeloma/Multiple Myeloma', + "Hodgkin's Lymphoma", + "Indolent/Low Grade Non-Hodgkin's Lymphoma", +]; + From 40d30981d541910fa3d6cc5ca108072fd501bfee Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 20 Nov 2025 15:22:16 -0500 Subject: [PATCH 3/9] added AvailabilityTemplate table to store volunteer general availabilities with days of week, added unit tests, updated other parts to use new table --- backend/app/models/AvailabilityTemplate.py | 35 ++ backend/app/models/TimeBlock.py | 3 - backend/app/models/User.py | 4 +- backend/app/models/__init__.py | 5 +- backend/app/schemas/availability.py | 20 +- backend/app/schemas/user.py | 3 +- backend/app/seeds/users.py | 7 +- .../implementations/availability_service.py | 182 ++++-- .../services/implementations/match_service.py | 152 ++--- .../services/implementations/user_service.py | 151 ++++- backend/app/utilities/timezone_utils.py | 39 ++ ...1638c9_add_availability_templates_table.py | 40 ++ ...dda4b46776e9_drop_available_times_table.py | 34 ++ .../tests/unit/test_availability_service.py | 549 ++++++++++++++++++ backend/tests/unit/test_match_service.py | 163 ++++-- .../tests/unit/test_match_service_timezone.py | 356 ++++++++++++ backend/tests/unit/test_user.py | 2 +- backend/tests/unit/test_user_data_update.py | 282 ++++----- frontend/src/APIClients/authAPIClient.ts | 47 +- .../admin/userProfile/AvailabilitySection.tsx | 133 +++-- frontend/src/hooks/useAvailabilityEditing.ts | 148 ++--- frontend/src/types/userTypes.ts | 8 +- 22 files changed, 1841 insertions(+), 522 deletions(-) create mode 100644 backend/app/models/AvailabilityTemplate.py create mode 100644 backend/app/utilities/timezone_utils.py create mode 100644 backend/migrations/versions/2141551638c9_add_availability_templates_table.py create mode 100644 backend/migrations/versions/dda4b46776e9_drop_available_times_table.py create mode 100644 backend/tests/unit/test_availability_service.py create mode 100644 backend/tests/unit/test_match_service_timezone.py diff --git a/backend/app/models/AvailabilityTemplate.py b/backend/app/models/AvailabilityTemplate.py new file mode 100644 index 00000000..7425eed6 --- /dev/null +++ b/backend/app/models/AvailabilityTemplate.py @@ -0,0 +1,35 @@ +from sqlalchemy import Boolean, Column, ForeignKey, Integer, Time +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from sqlalchemy import DateTime + +from .Base import Base + + +class AvailabilityTemplate(Base): + """ + Stores recurring weekly availability patterns for volunteers. + Each template represents a time slot on a specific day of the week. + These templates are projected forward to create specific TimeBlocks for matches. + """ + __tablename__ = "availability_templates" + + id = Column(Integer, primary_key=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + # Day of week: 0=Monday, 1=Tuesday, ..., 6=Sunday + day_of_week = Column(Integer, nullable=False) + + # Time of day (just time, no date) + start_time = Column(Time, nullable=False) # e.g., 14:00:00 + end_time = Column(Time, nullable=False) # e.g., 16:00:00 + + # Optional: for future enhancements (e.g., temporarily disable a template) + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="availability_templates") + diff --git a/backend/app/models/TimeBlock.py b/backend/app/models/TimeBlock.py index f435c837..4796b1a8 100644 --- a/backend/app/models/TimeBlock.py +++ b/backend/app/models/TimeBlock.py @@ -14,6 +14,3 @@ class TimeBlock(Base): # suggested matches suggested_matches = relationship("Match", secondary="suggested_times", back_populates="suggested_time_blocks") - - # the availability that the timeblock is a part of for a given user - users = relationship("User", secondary="available_times", back_populates="availability") diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 4ba0ef89..c500e66a 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -45,8 +45,8 @@ class User(Base): role = relationship("Role") - # time blocks in an availability for a user - availability = relationship("TimeBlock", secondary="available_times", back_populates="users") + # recurring availability templates (day of week + time) + availability_templates = relationship("AvailabilityTemplate", back_populates="user") participant_matches = relationship("Match", back_populates="participant", foreign_keys=[Match.participant_id]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5df2612b..8e5c73ab 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,10 +5,9 @@ from app.utilities.constants import LOGGER_NAME -from .AvailableTime import available_times - # Make sure all models are here to reflect all current models # when autogenerating new migration +from .AvailabilityTemplate import AvailabilityTemplate from .Base import Base from .Experience import Experience from .Form import Form @@ -35,8 +34,8 @@ "Match", "MatchStatus", "User", - "available_times", "suggested_times", + "AvailabilityTemplate", "UserData", "Treatment", "Experience", diff --git a/backend/app/schemas/availability.py b/backend/app/schemas/availability.py index 23568d21..590a537a 100644 --- a/backend/app/schemas/availability.py +++ b/backend/app/schemas/availability.py @@ -1,14 +1,22 @@ from typing import List from uuid import UUID +from datetime import time from pydantic import BaseModel -from app.schemas.time_block import TimeBlockEntity, TimeRange +from app.schemas.time_block import TimeBlockEntity + + +class AvailabilityTemplateSlot(BaseModel): + """Represents a single availability template slot (day of week + time range)""" + day_of_week: int # 0=Monday, 1=Tuesday, ..., 6=Sunday + start_time: time # e.g., 14:00:00 + end_time: time # e.g., 16:00:00 class CreateAvailabilityRequest(BaseModel): user_id: UUID - available_times: List[TimeRange] + templates: List[AvailabilityTemplateSlot] class CreateAvailabilityResponse(BaseModel): @@ -22,17 +30,15 @@ class GetAvailabilityRequest(BaseModel): class AvailabilityEntity(BaseModel): user_id: UUID - available_times: List[TimeBlockEntity] + templates: List[AvailabilityTemplateSlot] class DeleteAvailabilityRequest(BaseModel): user_id: UUID - delete: list[TimeRange] = [] + templates: List[AvailabilityTemplateSlot] = [] class DeleteAvailabilityResponse(BaseModel): user_id: UUID deleted: int - - # return the user’s availability after the update - availability: List[TimeBlockEntity] + templates: List[AvailabilityTemplateSlot] # remaining templates after deletion diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3b095e29..39d4c28a 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -12,6 +12,7 @@ from .time_block import TimeBlockEntity from .user_data import UserDataResponse from .volunteer_data import VolunteerDataResponse +from .availability import AvailabilityTemplateSlot # TODO: # confirm complexity rules for fields (such as password) @@ -143,7 +144,7 @@ class UserResponse(BaseModel): form_status: FormStatus user_data: Optional[UserDataResponse] = None volunteer_data: Optional[VolunteerDataResponse] = None - availability: List[TimeBlockEntity] = [] + availability: List[AvailabilityTemplateSlot] = [] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 34a51920..14088371 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -15,6 +15,7 @@ from app.models.FormSubmission import FormSubmission from app.models.Task import Task from app.models.SuggestedTime import suggested_times +from app.models.AvailabilityTemplate import AvailabilityTemplate from app.utilities.form_constants import ExperienceId, TreatmentId from sqlalchemy import delete @@ -360,10 +361,8 @@ def seed_users(session: Session) -> None: if existing_user.volunteer_data: session.delete(existing_user.volunteer_data) - # Clear availability relationships (delete time blocks) - if existing_user.availability: - for time_block in list(existing_user.availability): - session.delete(time_block) + # Clear availability templates + session.query(AvailabilityTemplate).filter_by(user_id=existing_user.id).delete() # Now delete the user session.delete(existing_user) diff --git a/backend/app/services/implementations/availability_service.py b/backend/app/services/implementations/availability_service.py index 1a99d27c..4f5a7bc7 100644 --- a/backend/app/services/implementations/availability_service.py +++ b/backend/app/services/implementations/availability_service.py @@ -1,12 +1,14 @@ import logging -from datetime import timedelta +from datetime import timedelta, time as dt_time +from typing import List from fastapi import HTTPException from sqlalchemy.orm import Session -from app.models import TimeBlock, User, available_times +from app.models import AvailabilityTemplate, User from app.schemas.availability import ( AvailabilityEntity, + AvailabilityTemplateSlot, CreateAvailabilityRequest, CreateAvailabilityResponse, DeleteAvailabilityRequest, @@ -22,13 +24,28 @@ def __init__(self, db: Session): async def get_availability(self, req: GetAvailabilityRequest) -> AvailabilityEntity: """ - Takes a user_id and outputs all time_blocks in a user's Availability + Takes a user_id and returns availability templates. """ try: user_id = req.user_id user = self.db.query(User).filter_by(id=user_id).one() + + # Get templates + templates = self.db.query(AvailabilityTemplate).filter_by( + user_id=user_id, is_active=True + ).all() + + # Convert to response format + template_slots: List[AvailabilityTemplateSlot] = [] + for template in templates: + template_slots.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + validated_data = AvailabilityEntity.model_validate( - {"user_id": user_id, "available_times": user.availability} + {"user_id": user_id, "templates": template_slots} ) return validated_data @@ -38,40 +55,69 @@ async def get_availability(self, req: GetAvailabilityRequest) -> AvailabilityEnt async def create_availability(self, availability: CreateAvailabilityRequest) -> CreateAvailabilityResponse: """ - Takes a user_id and a range of desired times. - Creates 30 minute time blocks spaced 30 minutes apart for the user's Availability. - Existing TimeBlocks in add will be silently ignored. + Takes a user_id and template slots (day_of_week + time ranges). + Converts these to AvailabilityTemplate records. + Replaces all existing templates for the user. """ added = 0 try: user_id = availability.user_id user = self.db.query(User).filter_by(id=user_id).one() - # get user's existing times and create a set - existing_start_times = {tb.start_time for tb in user.availability} - - for time_range in availability.available_times: - # time format looks like: 2025-03-17 09:30:00 - # modify based on the format - start_time = time_range.start_time - end_time = time_range.end_time - - # create timeblocks (0.5 hr) with 30 min spacing - current_start_time = start_time - while current_start_time < end_time: - self.logger.error(current_start_time) - # check if TimeBlock exists - if current_start_time not in existing_start_times: - time_block = TimeBlock(start_time=current_start_time) - user.availability.append(time_block) + + # Delete all existing templates for this user + self.db.query(AvailabilityTemplate).filter_by(user_id=user_id).delete() + + # Track templates we've seen to avoid duplicates + seen_templates = set() + + for template_slot in availability.templates: + # Validate day_of_week + if not (0 <= template_slot.day_of_week <= 6): + raise HTTPException( + status_code=400, + detail=f"Invalid day_of_week: {template_slot.day_of_week}. Must be 0-6 (Monday-Sunday)" + ) + + # Validate time range + if template_slot.end_time <= template_slot.start_time: + raise HTTPException( + status_code=400, + detail=f"end_time must be after start_time" + ) + + # Create template for each 30-minute block in the range + current_time = template_slot.start_time + end_time = template_slot.end_time + + while current_time < end_time: + # Calculate next 30-minute increment + next_time = self._add_minutes(current_time, 30) + if next_time > end_time: + next_time = end_time + + template_key = (template_slot.day_of_week, current_time) + + if template_key not in seen_templates: + template = AvailabilityTemplate( + user_id=user_id, + day_of_week=template_slot.day_of_week, + start_time=current_time, + end_time=next_time, + is_active=True + ) + self.db.add(template) + seen_templates.add(template_key) added += 1 - - # update current time by 30 minutes for the next block - current_start_time += timedelta(hours=0.5) + + current_time = next_time self.db.flush() validated_data = CreateAvailabilityResponse.model_validate({"user_id": user_id, "added": added}) self.db.commit() return validated_data + except HTTPException: + self.db.rollback() + raise except Exception as e: self.db.rollback() self.logger.error(f"Error creating availability: {str(e)}") @@ -79,37 +125,64 @@ async def create_availability(self, availability: CreateAvailabilityRequest) -> async def delete_availability(self, req: DeleteAvailabilityRequest) -> DeleteAvailabilityResponse: """ - Takes a DeleteAvailabilityRequest: - - delete: TimeBlocks in Availability that should be deleted - - Non-existent TimeBlocks in delete will be silently ignored. + Takes a DeleteAvailabilityRequest with template slots. + Deletes matching AvailabilityTemplate records. + Non-existent templates will be silently ignored. """ deleted = 0 try: - user: User = self.db.query(User).filter(User.id == req.user_id).one() - - # delete - for time_range in req.delete: - curr_start = time_range.start_time - while curr_start < time_range.end_time: - block = ( - self.db.query(TimeBlock) - .join(available_times, TimeBlock.id == available_times.c.time_block_id) - .filter(available_times.c.user_id == user.id, TimeBlock.start_time == curr_start) - .first() + user_id = req.user_id + user = self.db.query(User).filter(User.id == user_id).one() + + # Collect templates to delete + templates_to_delete = set() + + for template_slot in req.templates: + # Validate day_of_week + if not (0 <= template_slot.day_of_week <= 6): + self.logger.warning(f"Skipping invalid day_of_week: {template_slot.day_of_week}") + continue + + # Find all templates in this range + current_time = template_slot.start_time + end_time = template_slot.end_time + + while current_time < end_time: + templates_to_delete.add((template_slot.day_of_week, current_time)) + current_time = self._add_minutes(current_time, 30) + + # Delete matching templates + for day_of_week, time_val in templates_to_delete: + deleted_count = ( + self.db.query(AvailabilityTemplate) + .filter_by( + user_id=user_id, + day_of_week=day_of_week, + start_time=time_val, + is_active=True ) - - if block: - self.db.delete(block) - deleted += 1 - - curr_start += timedelta(hours=0.5) + .delete() + ) + deleted += deleted_count self.db.flush() + + # Get remaining templates for response + templates = self.db.query(AvailabilityTemplate).filter_by( + user_id=user_id, is_active=True + ).all() + + remaining_slots: List[AvailabilityTemplateSlot] = [] + for template in templates: + remaining_slots.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) response = DeleteAvailabilityResponse.model_validate( - {"user_id": req.user_id, "deleted": deleted, "availability": user.availability} + {"user_id": req.user_id, "deleted": deleted, "templates": remaining_slots} ) self.db.commit() @@ -119,3 +192,14 @@ async def delete_availability(self, req: DeleteAvailabilityRequest) -> DeleteAva self.db.rollback() self.logger.error(f"Error updating availability for user {req.user_id}: {e}") raise HTTPException(status_code=500, detail="Failed to update availability") + + @staticmethod + def _add_minutes(time_val: dt_time, minutes: int) -> dt_time: + """Add minutes to a time object, handling overflow.""" + total_minutes = time_val.hour * 60 + time_val.minute + minutes + hours = total_minutes // 60 + mins = total_minutes % 60 + if hours >= 24: + hours = 23 + mins = 59 + return dt_time(hours, mins, time_val.second, time_val.microsecond) diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index 7af6e3c5..3ee550fb 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -2,12 +2,14 @@ from datetime import date, datetime, timedelta, timezone from typing import List, Optional from uuid import UUID +from zoneinfo import ZoneInfo from fastapi import HTTPException from sqlalchemy.orm import Session, joinedload -from app.models import Match, MatchStatus, TimeBlock, User +from app.models import AvailabilityTemplate, Match, MatchStatus, TimeBlock, User from app.models.UserData import UserData +from app.utilities.timezone_utils import get_timezone_from_abbreviation from app.schemas.match import ( MatchCreateRequest, MatchCreateResponse, @@ -63,7 +65,10 @@ async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse: for volunteer_id in req.volunteer_ids: volunteer: User | None = ( - self.db.query(User).options(joinedload(User.availability)).filter(User.id == volunteer_id).first() + self.db.query(User) + .options(joinedload(User.user_data)) + .filter(User.id == volunteer_id) + .first() ) if not volunteer: raise HTTPException(404, f"Volunteer {volunteer_id} not found") @@ -114,7 +119,7 @@ async def update_match(self, match_id: int, req: MatchUpdateRequest) -> MatchRes if req.volunteer_id is not None and req.volunteer_id != match.volunteer_id: volunteer: User | None = ( self.db.query(User) - .options(joinedload(User.availability)) + .options(joinedload(User.user_data)) .filter(User.id == req.volunteer_id) .first() ) @@ -150,7 +155,7 @@ async def update_match(self, match_id: int, req: MatchUpdateRequest) -> MatchRes if final_status_name != "awaiting_volunteer_acceptance" and not match.suggested_time_blocks: volunteer_with_availability: User | None = ( self.db.query(User) - .options(joinedload(User.availability)) + .options(joinedload(User.user_data)) .filter(User.id == match.volunteer_id) .first() ) @@ -475,7 +480,7 @@ async def volunteer_accept_match( match: Match | None = ( self.db.query(Match) .options( - joinedload(Match.volunteer).joinedload(User.availability), + joinedload(Match.volunteer).joinedload(User.user_data), joinedload(Match.participant), joinedload(Match.match_status), ) @@ -774,91 +779,86 @@ def _calculate_age(birth_date: date) -> Optional[int]: return years if has_had_birthday else years - 1 def _has_valid_availability(self, volunteer: User) -> bool: - """Check if volunteer has any valid future availability blocks.""" - if not volunteer.availability: - return False + """Check if volunteer has any active availability templates.""" + template_count = ( + self.db.query(AvailabilityTemplate) + .filter_by(user_id=volunteer.id, is_active=True) + .count() + ) + + return template_count > 0 + def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None: + """ + Projects volunteer's availability templates onto the next 2 weeks + and creates TimeBlocks for the match's suggested times. + + Template times are interpreted in the volunteer's local timezone, + then converted to UTC for storage. + """ now = datetime.now(timezone.utc) - for block in volunteer.availability: - if block.start_time is None: - continue - if block.start_time < now: - continue - if block.start_time.minute not in {0, 30}: - continue - # Found at least one valid future availability block - return True - - return False + + # Get active availability templates for this volunteer + templates = ( + self.db.query(AvailabilityTemplate) + .filter_by(user_id=volunteer.id, is_active=True) + .all() + ) - def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None: - if not volunteer.availability: + if not templates: return - now = datetime.now(timezone.utc) + # Get volunteer's timezone from user_data + volunteer_tz: Optional[ZoneInfo] = None + if volunteer.user_data and volunteer.user_data.timezone: + volunteer_tz = get_timezone_from_abbreviation(volunteer.user_data.timezone) - # Define the projection window (e.g., next 2 weeks) - projection_weeks = 2 - - # Filter for template blocks (blocks in the past, specifically our reference week in 2000) - # We can just check if the year is 2000, or generally if it's far in the past. - # For robustness, let's assume any block before "now" is potentially a template if we are using this system. - # But strictly, our frontend sends 2000-01-XX. - - template_blocks = [ - tb for tb in volunteer.availability - if tb.start_time and tb.start_time.year == 2000 - ] - - if not template_blocks: - # Fallback for legacy data: use existing logic for non-template blocks - sorted_blocks = sorted( - volunteer.availability, - key=lambda tb: tb.start_time or now, + # Default to UTC if no timezone is set (shouldn't happen in production, but handle gracefully) + if not volunteer_tz: + self.logger.warning( + f"Volunteer {volunteer.id} has no timezone set. " + "Interpreting availability templates as UTC." ) - for block in sorted_blocks: - if block.start_time is None: - continue - if block.start_time < now: - continue - if block.start_time.minute not in {0, 30}: - continue - new_block = TimeBlock(start_time=block.start_time) - match.suggested_time_blocks.append(new_block) - return + volunteer_tz = timezone.utc - # Project template blocks onto the next `projection_weeks` weeks - # Find the next Monday to start the cycle - # If today is Monday, start today. If today is Tuesday, start next Monday? - # Usually availability is "next 2 weeks". Let's start from "tomorrow" or "today" and find matching days. + # Project templates onto the next week + projection_weeks = 1 - # Let's iterate through the next 14 days for day_offset in range(projection_weeks * 7): - target_date = now + timedelta(days=day_offset) - target_day_of_week = target_date.weekday() # 0=Mon, 6=Sun + # Calculate target date in UTC + target_date_utc = now + timedelta(days=day_offset) - # Find templates that match this day of week - # Template reference: Jan 3, 2000 was a Monday. - # Jan 3 (Mon) -> weekday 0 - # ... - # Jan 9 (Sun) -> weekday 6 + # Convert UTC date to volunteer's local date to get the correct weekday + # Templates are defined in the volunteer's local timezone, so we must + # compare against the local weekday, not the UTC weekday + target_date_local = target_date_utc.astimezone(volunteer_tz).date() + target_day_of_week = target_date_local.weekday() # 0=Mon, 6=Sun (in volunteer's timezone) - for template in template_blocks: - if template.start_time.weekday() == target_day_of_week: - # Create a new block for this target date with the template's time - new_start_time = target_date.replace( - hour=template.start_time.hour, - minute=template.start_time.minute, - second=0, - microsecond=0 - ) + # Find templates that match this day of week + for template in templates: + if template.day_of_week == target_day_of_week: + # Create datetime in volunteer's local timezone + current_time_local = datetime.combine( + target_date_local, + template.start_time + ).replace(tzinfo=volunteer_tz) + + end_time_local = datetime.combine( + target_date_local, + template.end_time + ).replace(tzinfo=volunteer_tz) + + # Convert to UTC for storage + current_time_utc = current_time_local.astimezone(timezone.utc) + end_time_utc = end_time_local.astimezone(timezone.utc) - # Ensure we don't add blocks in the past (if we started from 'now' and time has passed today) - if new_start_time < now: - continue + while current_time_utc < end_time_utc: + # Ensure we don't add blocks in the past + if current_time_utc >= now: + new_block = TimeBlock(start_time=current_time_utc) + match.suggested_time_blocks.append(new_block) - new_block = TimeBlock(start_time=new_start_time) - match.suggested_time_blocks.append(new_block) + current_time_utc += timedelta(minutes=30) def _reassign_volunteer(self, match: Match, volunteer: User) -> None: match.volunteer_id = volunteer.id diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 470d8800..fa6abcea 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -161,14 +161,36 @@ async def get_user_by_id(self, user_id: str) -> UserResponse: joinedload(User.user_data).joinedload(UserData.loved_one_treatments), joinedload(User.user_data).joinedload(UserData.loved_one_experiences), joinedload(User.volunteer_data), - joinedload(User.availability), + joinedload(User.availability_templates), ) .filter(User.id == UUID(user_id)) .first() ) if not user: raise HTTPException(status_code=404, detail="User not found") - return UserResponse.model_validate(user) + + # Convert templates to AvailabilityTemplateSlot for UserResponse + from app.schemas.availability import AvailabilityTemplateSlot + + availability_templates = [] + for template in user.availability_templates: + if template.is_active: + availability_templates.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + + # Create a temporary user object with availability for validation + user_dict = { + **{c.name: getattr(user, c.name) for c in user.__table__.columns}, + 'availability': availability_templates, + 'role': user.role, + 'user_data': user.user_data, + 'volunteer_data': user.volunteer_data, + } + + return UserResponse.model_validate(user_dict) except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") except HTTPException: @@ -200,12 +222,36 @@ async def get_users(self) -> List[UserResponse]: joinedload(User.role), joinedload(User.user_data), joinedload(User.volunteer_data), - joinedload(User.availability), + joinedload(User.availability_templates), ) .filter(User.role_id.in_([1, 2])) .all() ) - return [UserResponse.model_validate(user) for user in users] + + # Convert templates to AvailabilityTemplateSlot for each user + from app.schemas.availability import AvailabilityTemplateSlot + + user_responses = [] + for user in users: + availability_templates = [] + for template in user.availability_templates: + if template.is_active: + availability_templates.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + + user_dict = { + **{c.name: getattr(user, c.name) for c in user.__table__.columns}, + 'availability': availability_templates, + 'role': user.role, + 'user_data': user.user_data, + 'volunteer_data': user.volunteer_data, + } + user_responses.append(UserResponse.model_validate(user_dict)) + + return user_responses except Exception as e: self.logger.error(f"Error getting users: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -213,8 +259,40 @@ async def get_users(self) -> List[UserResponse]: async def get_admins(self) -> List[UserResponse]: try: # Get only admin users (role_id 3) - users = self.db.query(User).join(Role).filter(User.role_id == 3).all() - return [UserResponse.model_validate(user) for user in users] + users = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.availability_templates), + ) + .filter(User.role_id == 3) + .all() + ) + + # Convert templates to AvailabilityTemplateSlot for each admin (though admins typically don't have availability) + from app.schemas.availability import AvailabilityTemplateSlot + + user_responses = [] + for user in users: + availability_templates = [] + for template in user.availability_templates: + if template.is_active: + availability_templates.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + + user_dict = { + **{c.name: getattr(user, c.name) for c in user.__table__.columns}, + 'availability': availability_templates, + 'role': user.role, + 'user_data': user.user_data, + 'volunteer_data': user.volunteer_data, + } + user_responses.append(UserResponse.model_validate(user_dict)) + + return user_responses except Exception as e: self.logger.error(f"Error retrieving admin users: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -244,9 +322,38 @@ async def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) self.db.commit() self.db.refresh(db_user) - # return user with role information - updated_user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() - return UserResponse.model_validate(updated_user) + # return user with role information and availability + updated_user = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.availability_templates), + ) + .filter(User.id == UUID(user_id)) + .first() + ) + + # Convert templates to AvailabilityTemplateSlot for UserResponse + from app.schemas.availability import AvailabilityTemplateSlot + + availability_templates = [] + for template in updated_user.availability_templates: + if template.is_active: + availability_templates.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + + user_dict = { + **{c.name: getattr(updated_user, c.name) for c in updated_user.__table__.columns}, + 'availability': availability_templates, + 'role': updated_user.role, + 'user_data': updated_user.user_data, + 'volunteer_data': updated_user.volunteer_data, + } + + return UserResponse.model_validate(user_dict) except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") @@ -354,12 +461,34 @@ async def update_user_data_by_id(self, user_id: str, user_data_update: UserDataU joinedload(User.user_data).joinedload(UserData.loved_one_treatments), joinedload(User.user_data).joinedload(UserData.loved_one_experiences), joinedload(User.volunteer_data), - joinedload(User.availability), + joinedload(User.availability_templates), ) .filter(User.id == UUID(user_id)) .first() ) - return UserResponse.model_validate(updated_user) + + # Convert templates to AvailabilityTemplateSlot for UserResponse (same as get_user_by_id) + from app.schemas.availability import AvailabilityTemplateSlot + + availability_templates = [] + for template in updated_user.availability_templates: + if template.is_active: + availability_templates.append(AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time + )) + + # Create a temporary user object with availability for validation + user_dict = { + **{c.name: getattr(updated_user, c.name) for c in updated_user.__table__.columns}, + 'availability': availability_templates, + 'role': updated_user.role, + 'user_data': updated_user.user_data, + 'volunteer_data': updated_user.volunteer_data, + } + + return UserResponse.model_validate(user_dict) except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") diff --git a/backend/app/utilities/timezone_utils.py b/backend/app/utilities/timezone_utils.py new file mode 100644 index 00000000..f80c3275 --- /dev/null +++ b/backend/app/utilities/timezone_utils.py @@ -0,0 +1,39 @@ +""" +Utility functions for handling Canadian timezone abbreviations. +Maps abbreviations (NST, AST, EST, CST, MST, PST) to IANA timezone identifiers. +""" +from zoneinfo import ZoneInfo +from typing import Optional + + +# Map Canadian timezone abbreviations to IANA timezone identifiers +CANADIAN_TIMEZONE_MAP = { + "NST": ZoneInfo("America/St_Johns"), # Newfoundland Standard Time + "AST": ZoneInfo("America/Halifax"), # Atlantic Standard Time + "EST": ZoneInfo("America/Toronto"), # Eastern Standard Time + "CST": ZoneInfo("America/Winnipeg"), # Central Standard Time + "MST": ZoneInfo("America/Edmonton"), # Mountain Standard Time + "PST": ZoneInfo("America/Vancouver"), # Pacific Standard Time +} + + +def get_timezone_from_abbreviation(abbreviation: Optional[str]) -> Optional[ZoneInfo]: + """ + Convert a Canadian timezone abbreviation to a ZoneInfo object. + + Args: + abbreviation: One of NST, AST, EST, CST, MST, PST, or None + + Returns: + ZoneInfo object for the timezone, or None if abbreviation is None/invalid + + Examples: + >>> tz = get_timezone_from_abbreviation("EST") + >>> tz + zoneinfo.ZoneInfo(key='America/Toronto') + """ + if not abbreviation: + return None + + return CANADIAN_TIMEZONE_MAP.get(abbreviation.upper()) + diff --git a/backend/migrations/versions/2141551638c9_add_availability_templates_table.py b/backend/migrations/versions/2141551638c9_add_availability_templates_table.py new file mode 100644 index 00000000..fa5fa064 --- /dev/null +++ b/backend/migrations/versions/2141551638c9_add_availability_templates_table.py @@ -0,0 +1,40 @@ +"""add availability_templates table + +Revision ID: 2141551638c9 +Revises: 23dae9594e1d +Create Date: 2025-11-20 14:20:36.802308 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '2141551638c9' +down_revision: Union[str, None] = '23dae9594e1d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('availability_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('day_of_week', sa.Integer(), nullable=False), + sa.Column('start_time', sa.Time(), nullable=False), + sa.Column('end_time', sa.Time(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('availability_templates') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py b/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py new file mode 100644 index 00000000..b4668cf3 --- /dev/null +++ b/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py @@ -0,0 +1,34 @@ +"""drop_available_times_table + +Revision ID: dda4b46776e9 +Revises: 2141551638c9 +Create Date: 2025-11-20 14:40:37.626596 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'dda4b46776e9' +down_revision: Union[str, None] = '2141551638c9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the available_times association table (replaced by availability_templates) + op.drop_table('available_times') + + +def downgrade() -> None: + # Recreate available_times table (for rollback purposes) + op.create_table( + 'available_times', + sa.Column('time_block_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['time_block_id'], ['time_blocks.id']), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.PrimaryKeyConstraint('time_block_id', 'user_id') + ) diff --git a/backend/tests/unit/test_availability_service.py b/backend/tests/unit/test_availability_service.py new file mode 100644 index 00000000..8151a1fc --- /dev/null +++ b/backend/tests/unit/test_availability_service.py @@ -0,0 +1,549 @@ +""" +Tests for AvailabilityService with the new template-based system. +Tests timezone handling, template creation, and projection. +""" +import os +import pytest +from datetime import date, datetime, timedelta, time as dt_time, timezone +from uuid import uuid4 +from zoneinfo import ZoneInfo +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.models import AvailabilityTemplate, Role, User, UserData +from app.schemas.user import UserRole +from app.schemas.availability import ( + AvailabilityTemplateSlot, + CreateAvailabilityRequest, + DeleteAvailabilityRequest, + GetAvailabilityRequest, +) +from app.services.implementations.availability_service import AvailabilityService + +# Test DB Configuration +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +if not POSTGRES_DATABASE_URL: + raise RuntimeError( + "POSTGRES_TEST_DATABASE_URL is not set. Please export a Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + ) +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test""" + session = TestingSessionLocal() + + try: + # Clean up any existing data first + session.execute( + text( + "TRUNCATE TABLE availability_templates, user_data, users RESTART IDENTITY CASCADE" + ) + ) + session.commit() + + # Ensure roles exist + existing = {r.id for r in session.query(Role).all()} + seed_roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for role in seed_roles: + if role.id not in existing: + session.add(role) + session.commit() + + yield session + finally: + session.close() + + +@pytest.fixture +def volunteer_user(db_session): + """Create a volunteer user with EST timezone""" + user = User( + id=uuid4(), + email="volunteer@test.com", + role_id=2, # VOLUNTEER + auth_id="auth_123", + first_name="Test", + last_name="Volunteer", + ) + db_session.add(user) + + user_data = UserData( + id=uuid4(), + user_id=user.id, + timezone="EST", + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def pst_volunteer(db_session): + """Create a volunteer user with PST timezone""" + user = User( + id=uuid4(), + email="pst_volunteer@test.com", + role_id=2, # VOLUNTEER + auth_id="auth_456", + first_name="PST", + last_name="Volunteer", + ) + db_session.add(user) + + user_data = UserData( + id=uuid4(), + user_id=user.id, + timezone="PST", + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.mark.asyncio +async def test_create_availability_adds_templates(db_session, volunteer_user): + """Test that creating availability adds templates correctly""" + availability_service = AvailabilityService(db_session) + + # Create templates: Monday 10:00 AM to 11:30 AM + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(11, 30), + ) + ] + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + templates=templates, + ) + + result = await availability_service.create_availability(create_request) + + assert result.user_id == volunteer_user.id + assert result.added == 3 # 10:00, 10:30, 11:00 (3 templates) + + # Verify templates were created + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 3 + times = {t.start_time for t in templates} + assert dt_time(10, 0) in times + assert dt_time(10, 30) in times + assert dt_time(11, 0) in times + # All should be Monday (day_of_week 0) + assert all(t.day_of_week == 0 for t in templates) + assert all(t.is_active for t in templates) + + +@pytest.mark.asyncio +async def test_create_availability_replaces_existing(db_session, volunteer_user): + """Test that creating availability replaces all existing templates""" + availability_service = AvailabilityService(db_session) + + # Create initial templates + templates1 = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates1) + ) + + # Create new templates (should replace old ones) + templates2 = [ + AvailabilityTemplateSlot( + day_of_week=1, # Tuesday + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + ) + ] + result = await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates2) + ) + + assert result.added == 2 # 14:00, 14:30 + + # Verify old templates are gone, new ones exist + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 2 + assert all(t.day_of_week == 1 for t in templates) # All Tuesday + times = {t.start_time for t in templates} + assert dt_time(14, 0) in times + assert dt_time(14, 30) in times + + +@pytest.mark.asyncio +async def test_create_availability_multiple_ranges(db_session, volunteer_user): + """Test creating availability with multiple time ranges""" + availability_service = AvailabilityService(db_session) + + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(9, 0), + end_time=dt_time(10, 0), + ), + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + ), + ] + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + templates=templates, + ) + + result = await availability_service.create_availability(create_request) + + assert result.added == 4 # 9:00, 9:30, 14:00, 14:30 + + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 4 + + +@pytest.mark.asyncio +async def test_get_availability_returns_templates(db_session, volunteer_user): + """Test that getting availability returns templates""" + availability_service = AvailabilityService(db_session) + + # Create templates + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + # Get availability + get_request = GetAvailabilityRequest(user_id=volunteer_user.id) + result = await availability_service.get_availability(get_request) + + assert result.user_id == volunteer_user.id + # Service creates individual 30-minute templates, so 10:00-11:00 creates 2 templates (10:00-10:30, 10:30-11:00) + assert len(result.templates) == 2 + assert all(t.day_of_week == 0 for t in result.templates) + times = {t.start_time for t in result.templates} + assert dt_time(10, 0) in times + assert dt_time(10, 30) in times + + +@pytest.mark.asyncio +async def test_get_availability_only_active(db_session, volunteer_user): + """Test that getting availability only returns active templates""" + availability_service = AvailabilityService(db_session) + + # Create active template + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + # Manually create inactive template + inactive_template = AvailabilityTemplate( + user_id=volunteer_user.id, + day_of_week=1, + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + is_active=False, + ) + db_session.add(inactive_template) + db_session.commit() + + # Get availability + get_request = GetAvailabilityRequest(user_id=volunteer_user.id) + result = await availability_service.get_availability(get_request) + + # Service creates 2 templates for 10:00-11:00 (30-minute blocks) + assert len(result.templates) == 2 + assert all(t.day_of_week == 0 for t in result.templates) # Only active templates + + +@pytest.mark.asyncio +async def test_delete_availability_removes_templates(db_session, volunteer_user): + """Test that deleting availability removes templates correctly""" + availability_service = AvailabilityService(db_session) + + # Create templates + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), # 10:00, 10:30, 11:00, 11:30 + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + # Delete part of availability + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), # Delete 10:00, 10:30 + ) + ] + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + templates=delete_templates, + ) + + result = await availability_service.delete_availability(delete_request) + + assert result.deleted == 2 + assert len(result.templates) == 2 # Remaining: 11:00, 11:30 + + # Verify remaining templates + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 2 + times = {t.start_time for t in remaining} + assert dt_time(11, 0) in times + assert dt_time(11, 30) in times + + +@pytest.mark.asyncio +async def test_delete_availability_ignores_non_existent(db_session, volunteer_user): + """Test that deleting availability ignores non-existent templates""" + availability_service = AvailabilityService(db_session) + + # Create some templates + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + # Try to delete non-existent templates + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=1, # Tuesday (doesn't exist) + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + ) + ] + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + templates=delete_templates, + ) + + result = await availability_service.delete_availability(delete_request) + + assert result.deleted == 0 + assert len(result.templates) == 2 # Original templates still there + + # Verify templates still exist + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 2 + + +@pytest.mark.asyncio +async def test_delete_all_availability(db_session, volunteer_user): + """Test deleting all availability""" + availability_service = AvailabilityService(db_session) + + # Create availability + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + # Delete all availability + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), + ) + ] + delete_request = DeleteAvailabilityRequest( + user_id=volunteer_user.id, + templates=delete_templates, + ) + + result = await availability_service.delete_availability(delete_request) + + assert result.deleted == 4 + assert len(result.templates) == 0 + + # Verify no templates remain + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 0 + + +@pytest.mark.asyncio +async def test_create_availability_invalid_day_of_week(db_session, volunteer_user): + """Test that invalid day_of_week raises error""" + availability_service = AvailabilityService(db_session) + + templates = [ + AvailabilityTemplateSlot( + day_of_week=7, # Invalid (should be 0-6) + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + templates=templates, + ) + + with pytest.raises(Exception): # Should raise HTTPException + await availability_service.create_availability(create_request) + + +@pytest.mark.asyncio +async def test_create_availability_invalid_time_range(db_session, volunteer_user): + """Test that invalid time range (end <= start) raises error""" + availability_service = AvailabilityService(db_session) + + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(11, 0), + end_time=dt_time(10, 0), # End before start + ) + ] + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + templates=templates, + ) + + with pytest.raises(Exception): # Should raise HTTPException + await availability_service.create_availability(create_request) + + +@pytest.mark.asyncio +async def test_create_availability_user_not_found(db_session): + """Test that creating availability raises error for non-existent user""" + availability_service = AvailabilityService(db_session) + + fake_user_id = uuid4() + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + create_request = CreateAvailabilityRequest( + user_id=fake_user_id, + templates=templates, + ) + + with pytest.raises(Exception): # Should raise HTTPException + await availability_service.create_availability(create_request) + + +@pytest.mark.asyncio +async def test_pst_user_can_submit_8am_to_8pm(db_session, pst_volunteer): + """Test that PST users can submit 8am-8pm PST templates""" + availability_service = AvailabilityService(db_session) + + # PST user submits 8am-8pm PST + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(8, 0), # 8am PST + end_time=dt_time(20, 0), # 8pm PST + ) + ] + + create_request = CreateAvailabilityRequest( + user_id=pst_volunteer.id, + templates=templates, + ) + + result = await availability_service.create_availability(create_request) + + # Should create 24 templates (8am-8pm in 30-min increments) + assert result.added == 24 + + # Verify templates stored as local time (not converted to UTC) + templates = db_session.query(AvailabilityTemplate).filter_by( + user_id=pst_volunteer.id, is_active=True + ).all() + assert len(templates) == 24 + + # Check first and last templates + times = sorted([t.start_time for t in templates]) + assert times[0] == dt_time(8, 0) # 8am PST + assert times[-1] == dt_time(19, 30) # 7:30pm PST (last 30-min block before 8pm) + + +@pytest.mark.asyncio +async def test_est_user_can_submit_8am_to_8pm(db_session, volunteer_user): + """Test that EST users can submit 8am-8pm EST templates""" + availability_service = AvailabilityService(db_session) + + # EST user submits 8am-8pm EST + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(8, 0), # 8am EST + end_time=dt_time(20, 0), # 8pm EST + ) + ] + + create_request = CreateAvailabilityRequest( + user_id=volunteer_user.id, + templates=templates, + ) + + result = await availability_service.create_availability(create_request) + + # Should create 24 templates + assert result.added == 24 + + # Verify templates stored as local time + templates = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(templates) == 24 + + times = sorted([t.start_time for t in templates]) + assert times[0] == dt_time(8, 0) # 8am EST + assert times[-1] == dt_time(19, 30) # 7:30pm EST + diff --git a/backend/tests/unit/test_match_service.py b/backend/tests/unit/test_match_service.py index aa1901bd..9c5533b7 100644 --- a/backend/tests/unit/test_match_service.py +++ b/backend/tests/unit/test_match_service.py @@ -14,12 +14,13 @@ from uuid import UUID import pytest +import pytest_asyncio from fastapi import HTTPException from sqlalchemy import create_engine, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker -from app.models import Match, MatchStatus, Role, TimeBlock, User +from app.models import AvailabilityTemplate, Match, MatchStatus, Role, TimeBlock, User, UserData from app.schemas.match import ( MatchCreateRequest, MatchRequestNewVolunteersResponse, @@ -28,6 +29,10 @@ from app.schemas.time_block import TimeRange from app.schemas.user import UserRole from app.services.implementations.match_service import MatchService +from app.services.implementations.availability_service import AvailabilityService +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest +from app.services.implementations.availability_service import AvailabilityService +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest # Check for Postgres test database (same pattern as test_user.py) POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") @@ -56,7 +61,7 @@ def db_session(): # Clean up match-related data (be careful with FK constraints) session.execute( text( - "TRUNCATE TABLE suggested_times, available_times, matches, time_blocks, tasks RESTART IDENTITY CASCADE" + "TRUNCATE TABLE suggested_times, availability_templates, matches, time_blocks, tasks RESTART IDENTITY CASCADE" ) ) session.execute(text("TRUNCATE TABLE users RESTART IDENTITY CASCADE")) @@ -77,8 +82,9 @@ def db_session(): except IntegrityError: session.rollback() - # Seed match statuses if missing - existing_statuses = {s.id for s in session.query(MatchStatus).all()} + # Seed match statuses - always ensure they exist + existing_statuses = {s.name for s in session.query(MatchStatus).all()} + existing_status_ids = {s.id for s in session.query(MatchStatus).all()} seed_statuses = [ MatchStatus(id=1, name="pending"), MatchStatus(id=2, name="confirmed"), @@ -92,12 +98,15 @@ def db_session(): MatchStatus(id=10, name="awaiting_volunteer_acceptance"), ] for status in seed_statuses: - if status.id not in existing_statuses: - try: + if status.name not in existing_statuses: + # If ID exists but name doesn't match, update it + if status.id in existing_status_ids: + existing = session.query(MatchStatus).filter_by(id=status.id).first() + if existing: + existing.name = status.name + else: session.add(status) - session.commit() - except IntegrityError: - session.rollback() + session.commit() # Commit all statuses at once yield session finally: @@ -169,61 +178,67 @@ def another_volunteer(db_session): return user -@pytest.fixture -def volunteer_with_availability(db_session, volunteer_user): - """Create volunteer with future availability on half-hour boundaries.""" - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - - # Create availability: tomorrow at 10:00, 10:30, 11:00, 11:30 - times = [ - tomorrow.replace(hour=10, minute=0, second=0, microsecond=0), - tomorrow.replace(hour=10, minute=30, second=0, microsecond=0), - tomorrow.replace(hour=11, minute=0, second=0, microsecond=0), - tomorrow.replace(hour=11, minute=30, second=0, microsecond=0), - ] - - for time in times: - block = TimeBlock(start_time=time) - volunteer_user.availability.append(block) - +@pytest_asyncio.fixture +async def volunteer_with_availability(db_session, volunteer_user): + """Create volunteer with availability templates.""" + # Create user_data with EST timezone + user_data = UserData( + user_id=volunteer_user.id, + timezone="EST", + ) + db_session.add(user_data) db_session.commit() db_session.refresh(volunteer_user) - return volunteer_user - - -@pytest.fixture -def volunteer_with_mixed_availability(db_session, another_volunteer): - """Create volunteer with past times and non-half-hour times (should be filtered).""" - now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - tomorrow = now + timedelta(days=1) - - times = [ - # Past time (should be filtered) - yesterday.replace(hour=10, minute=0, second=0, microsecond=0), - # Non-half-hour (should be filtered) - tomorrow.replace(hour=10, minute=15, second=0, microsecond=0), - # Valid future times - tomorrow.replace(hour=14, minute=0, second=0, microsecond=0), - tomorrow.replace(hour=14, minute=30, second=0, microsecond=0), + + # Create availability templates: Monday 10:00-12:00 EST + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=datetime(2000, 1, 1, 10, 0).time(), # 10:00 + end_time=datetime(2000, 1, 1, 12, 0).time(), # 12:00 + ) ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) + ) + + db_session.refresh(volunteer_user) + return volunteer_user - for time in times: - block = TimeBlock(start_time=time) - another_volunteer.availability.append(block) +@pytest_asyncio.fixture +async def volunteer_with_mixed_availability(db_session, another_volunteer): + """Create volunteer with availability templates.""" + # Create user_data with EST timezone + user_data = UserData( + user_id=another_volunteer.id, + timezone="EST", + ) + db_session.add(user_data) db_session.commit() + db_session.refresh(another_volunteer) + + # Create availability templates: Tuesday 14:00-15:00 EST + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=1, # Tuesday + start_time=datetime(2000, 1, 1, 14, 0).time(), # 14:00 + end_time=datetime(2000, 1, 1, 15, 0).time(), # 15:00 + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=another_volunteer.id, templates=templates) + ) + db_session.refresh(another_volunteer) return another_volunteer -@pytest.fixture -def volunteer_with_alt_availability(db_session): - """Create a different volunteer with distinct availability.""" - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=2) - +@pytest_asyncio.fixture +async def volunteer_with_alt_availability(db_session): + """Create a different volunteer with distinct availability templates.""" volunteer = User( first_name="Alt", last_name="Volunteer", @@ -233,13 +248,28 @@ def volunteer_with_alt_availability(db_session): ) db_session.add(volunteer) db_session.flush() - - slots = [ - tomorrow.replace(hour=9, minute=0, second=0, microsecond=0), - tomorrow.replace(hour=9, minute=30, second=0, microsecond=0), + + # Create user_data with EST timezone + user_data = UserData( + user_id=volunteer.id, + timezone="EST", + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(volunteer) + + # Create availability templates: Wednesday 9:00-10:00 EST + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=2, # Wednesday + start_time=datetime(2000, 1, 1, 9, 0).time(), # 9:00 + end_time=datetime(2000, 1, 1, 10, 0).time(), # 10:00 + ) ] - for slot in slots: - volunteer.availability.append(TimeBlock(start_time=slot)) + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer.id, templates=templates) + ) db_session.commit() db_session.refresh(volunteer) @@ -330,10 +360,12 @@ async def test_create_match_copies_volunteer_availability( detail = await match_service.volunteer_accept_match(match_id, volunteer_with_availability.id) assert detail.match_status == "pending" + # Templates project to next week, so 10:00-12:00 Monday = 4 blocks per week = 4 blocks assert len(detail.suggested_time_blocks) == 4 db_session.refresh(match) assert match.match_status.name == "pending" + # Templates project to next week, so 10:00-12:00 Monday = 4 blocks per week = 4 blocks assert len(match.suggested_time_blocks) == 4 for block in match.suggested_time_blocks: @@ -441,6 +473,7 @@ async def test_create_match_with_pending_status_copies_availability( match = db_session.get(Match, match_id) assert match is not None assert match.match_status.name == "pending" + # Templates project to next week, so 10:00-12:00 Monday = 4 blocks per week = 4 blocks assert len(match.suggested_time_blocks) == 4 for block in match.suggested_time_blocks: @@ -1645,9 +1678,13 @@ async def test_update_match_reassigns_volunteer_resets_suggested_times( db_session.refresh(match) assert match.match_status.name == "pending" - starts = sorted(block.start_time for block in match.suggested_time_blocks) - expected = sorted([slot.start_time for slot in volunteer_with_alt_availability.availability]) - assert starts == expected + # Verify suggested times were generated from templates + # Templates project to next week, so we should have some suggested times + assert len(match.suggested_time_blocks) > 0 + # Verify all times are in UTC and on half-hour boundaries + for block in match.suggested_time_blocks: + assert block.start_time.tzinfo == timezone.utc + assert block.start_time.minute in {0, 30} db_session.commit() except Exception: diff --git a/backend/tests/unit/test_match_service_timezone.py b/backend/tests/unit/test_match_service_timezone.py new file mode 100644 index 00000000..aadbe12e --- /dev/null +++ b/backend/tests/unit/test_match_service_timezone.py @@ -0,0 +1,356 @@ +""" +Tests for MatchService timezone handling when projecting availability templates. +""" +import os +import pytest +import pytest_asyncio +from datetime import datetime, timedelta, time as dt_time, timezone +from uuid import uuid4 +from zoneinfo import ZoneInfo +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.models import AvailabilityTemplate, Match, MatchStatus, Role, TimeBlock, User, UserData +from app.schemas.match import MatchCreateRequest +from app.schemas.user import UserRole +from app.services.implementations.availability_service import AvailabilityService +from app.services.implementations.match_service import MatchService +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest + +# Test DB Configuration +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +if not POSTGRES_DATABASE_URL: + raise RuntimeError( + "POSTGRES_TEST_DATABASE_URL is not set. Please export a Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + ) +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test""" + session = TestingSessionLocal() + + try: + # Clean up any existing data first + session.execute( + text( + "TRUNCATE TABLE suggested_times, time_blocks, matches, " + "availability_templates, user_data, users RESTART IDENTITY CASCADE" + ) + ) + session.commit() + + # Ensure roles exist + existing = {r.id for r in session.query(Role).all()} + seed_roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for role in seed_roles: + if role.id not in existing: + session.add(role) + + # Ensure match statuses exist + existing_statuses = {s.name for s in session.query(MatchStatus).all()} + statuses = [ + MatchStatus(name="pending"), + MatchStatus(name="awaiting_volunteer_acceptance"), + MatchStatus(name="confirmed"), + ] + for status in statuses: + if status.name not in existing_statuses: + session.add(status) + + session.commit() + + yield session + finally: + session.close() + + +@pytest.fixture +def participant_user(db_session): + """Create a test participant""" + user = User( + id=uuid4(), + email="participant@test.com", + role_id=1, # PARTICIPANT + auth_id="auth_participant", + first_name="Test", + last_name="Participant", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def est_volunteer(db_session): + """Create volunteer with EST timezone and availability templates""" + user = User( + id=uuid4(), + email="est_volunteer@test.com", + role_id=2, # VOLUNTEER + auth_id="auth_est", + first_name="EST", + last_name="Volunteer", + ) + db_session.add(user) + + user_data = UserData( + id=uuid4(), + user_id=user.id, + timezone="EST", + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(user) + + # Create availability templates: Monday 2pm-4pm EST + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(14, 0), # 2pm EST + end_time=dt_time(16, 0), # 4pm EST + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=user.id, templates=templates) + ) + + db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def pst_volunteer(db_session): + """Create volunteer with PST timezone and availability templates""" + user = User( + id=uuid4(), + email="pst_volunteer@test.com", + role_id=2, # VOLUNTEER + auth_id="auth_pst", + first_name="PST", + last_name="Volunteer", + ) + db_session.add(user) + + user_data = UserData( + id=uuid4(), + user_id=user.id, + timezone="PST", + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(user) + + # Create availability templates: Monday 8am-10am PST + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(8, 0), # 8am PST + end_time=dt_time(10, 0), # 10am PST + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=user.id, templates=templates) + ) + + db_session.refresh(user) + return user + + +@pytest.mark.asyncio +async def test_est_template_projects_to_utc_correctly(db_session, participant_user, est_volunteer): + """Test that EST templates project to correct UTC times""" + match_service = MatchService(db_session) + + # Create match with EST volunteer + create_request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[est_volunteer.id], + match_status="pending", # This will trigger template projection + ) + + result = await match_service.create_matches(create_request) + assert len(result.matches) == 1 + + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() + assert match is not None + + # Get suggested time blocks + suggested_blocks = match.suggested_time_blocks + assert len(suggested_blocks) > 0 + + # EST is UTC-5 in winter, UTC-4 in summer (EDT) + # 2pm EST = 7pm UTC (winter) or 6pm UTC (summer) + # 4pm EST = 9pm UTC (winter) or 8pm UTC (summer) + # We should have blocks at the correct UTC times + utc_times = sorted([block.start_time for block in suggested_blocks]) + + # Check that times are in UTC + assert all(tz.tzinfo == timezone.utc for tz in utc_times) + + # Check that times are in the future + now = datetime.now(timezone.utc) + assert all(tz >= now for tz in utc_times) + + # Verify times correspond to Monday 2pm-4pm EST + # Find a Monday in the next week + for block in suggested_blocks: + if block.start_time.weekday() == 0: # Monday + hour_utc = block.start_time.hour + # EST is UTC-5 (winter) or UTC-4 (summer/EDT) + # 2pm EST = 7pm UTC (winter) or 6pm UTC (summer) + # 4pm EST = 9pm UTC (winter) or 8pm UTC (summer) + # Allow for both DST and non-DST + assert hour_utc in [18, 19, 20, 21], f"Expected 18-21 UTC (2-4pm EST), got {hour_utc}" + + +@pytest.mark.asyncio +async def test_pst_template_projects_to_utc_correctly(db_session, participant_user, pst_volunteer): + """Test that PST templates project to correct UTC times""" + match_service = MatchService(db_session) + + # Create match with PST volunteer + create_request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[pst_volunteer.id], + match_status="pending", + ) + + result = await match_service.create_matches(create_request) + assert len(result.matches) == 1 + + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() + assert match is not None + + # Get suggested time blocks + suggested_blocks = match.suggested_time_blocks + assert len(suggested_blocks) > 0 + + # PST is UTC-8 in winter, UTC-7 in summer (PDT) + # 8am PST = 4pm UTC (winter) or 3pm UTC (summer) + # 10am PST = 6pm UTC (winter) or 5pm UTC (summer) + utc_times = sorted([block.start_time for block in suggested_blocks]) + + # Check that times are in UTC + assert all(tz.tzinfo == timezone.utc for tz in utc_times) + + # Check that times are in the future + now = datetime.now(timezone.utc) + assert all(tz >= now for tz in utc_times) + + # Verify times correspond to Monday 8am-10am PST + for block in suggested_blocks: + if block.start_time.weekday() == 0: # Monday + hour_utc = block.start_time.hour + # PST is UTC-8 (winter) or UTC-7 (summer/PDT) + # 8am PST = 4pm UTC (winter) or 3pm UTC (summer) + # 10am PST = 6pm UTC (winter) or 5pm UTC (summer) + # Allow for both DST and non-DST + assert hour_utc in [15, 16, 17, 18], f"Expected 15-18 UTC (8-10am PST), got {hour_utc}" + + +@pytest.mark.asyncio +async def test_volunteer_accept_match_projects_templates(db_session, participant_user, est_volunteer): + """Test that volunteer accepting match projects templates correctly""" + match_service = MatchService(db_session) + + # Create match with awaiting_volunteer_acceptance status + create_request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[est_volunteer.id], + match_status="awaiting_volunteer_acceptance", + ) + + result = await match_service.create_matches(create_request) + assert len(result.matches) == 1 + + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() + # Initially no suggested times (awaiting acceptance) + assert len(match.suggested_time_blocks) == 0 + + # Volunteer accepts match + detail = await match_service.volunteer_accept_match(match.id, est_volunteer.id) + + # Refresh match + db_session.refresh(match) + + # Should now have suggested times projected from templates + assert len(match.suggested_time_blocks) > 0 + + # Verify times are in UTC + for block in match.suggested_time_blocks: + assert block.start_time.tzinfo == timezone.utc + + +@pytest.mark.asyncio +async def test_no_timezone_defaults_to_utc(db_session, participant_user): + """Test that volunteer without timezone defaults to UTC""" + # Create volunteer without timezone + volunteer = User( + id=uuid4(), + email="no_tz_volunteer@test.com", + role_id=2, + auth_id="auth_no_tz", + first_name="No", + last_name="Timezone", + ) + db_session.add(volunteer) + + user_data = UserData( + id=uuid4(), + user_id=volunteer.id, + timezone=None, # No timezone + ) + db_session.add(user_data) + db_session.commit() + db_session.refresh(volunteer) + + # Create availability templates + availability_service = AvailabilityService(db_session) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(14, 0), + end_time=dt_time(16, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer.id, templates=templates) + ) + + # Create match + match_service = MatchService(db_session) + create_request = MatchCreateRequest( + participant_id=participant_user.id, + volunteer_ids=[volunteer.id], + match_status="pending", + ) + + result = await match_service.create_matches(create_request) + assert len(result.matches) == 1 + + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() + + # Should still work (defaults to UTC) + # Templates interpreted as UTC, so 2pm UTC = 2pm UTC + suggested_blocks = match.suggested_time_blocks + assert len(suggested_blocks) > 0 + + # Verify times are in UTC + for block in suggested_blocks: + assert block.start_time.tzinfo == timezone.utc + if block.start_time.weekday() == 0: # Monday + # Without timezone, templates are interpreted as UTC, so 2pm template = 2pm UTC + # But DST might affect this, so allow for both 14 and 15 (depending on when test runs) + assert block.start_time.hour in [14, 15], f"Expected 14 or 15 UTC, got {block.start_time.hour}" + diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 6ecea9c2..23695cb6 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -87,7 +87,7 @@ def db_session(): session.execute( text( "TRUNCATE TABLE form_submissions, user_loved_one_experiences, user_loved_one_treatments, " - "user_experiences, user_treatments, available_times, matches, suggested_times, user_data, users " + "user_experiences, user_treatments, availability_templates, matches, suggested_times, user_data, users " "RESTART IDENTITY CASCADE" ) ) diff --git a/backend/tests/unit/test_user_data_update.py b/backend/tests/unit/test_user_data_update.py index 1b8d3c18..1b33a447 100644 --- a/backend/tests/unit/test_user_data_update.py +++ b/backend/tests/unit/test_user_data_update.py @@ -1,16 +1,19 @@ import os import pytest -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timedelta, time as dt_time, timezone from uuid import uuid4 from sqlalchemy import create_engine, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker -from app.models import Role, User, UserData, Treatment, Experience, TimeBlock +from app.models import AvailabilityTemplate, Role, User, UserData, Treatment, Experience, TimeBlock from app.schemas.user import UserRole from app.schemas.user_data import UserDataUpdateRequest -from app.schemas.availability import CreateAvailabilityRequest, DeleteAvailabilityRequest -from app.schemas.time_block import TimeRange +from app.schemas.availability import ( + AvailabilityTemplateSlot, + CreateAvailabilityRequest, + DeleteAvailabilityRequest, +) from app.services.implementations.user_service import UserService from app.services.implementations.availability_service import AvailabilityService @@ -38,7 +41,7 @@ def db_session(): session.execute( text( "TRUNCATE TABLE user_loved_one_experiences, user_loved_one_treatments, " - "user_experiences, user_treatments, available_times, time_blocks, " + "user_experiences, user_treatments, availability_templates, time_blocks, " "user_data, users RESTART IDENTITY CASCADE" ) ) @@ -488,65 +491,38 @@ def volunteer_user(db_session): @pytest.mark.asyncio -async def test_create_availability_adds_time_blocks(db_session, volunteer_user): - """Test that creating availability adds time blocks correctly""" +async def test_create_availability_adds_templates(db_session, volunteer_user): + """Test that creating availability adds templates correctly""" availability_service = AvailabilityService(db_session) - # Create a time range: tomorrow 10:00 AM to 11:30 AM (should create 3 blocks: 10:00, 10:30, 11:00) - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=11, minute=30, second=0, microsecond=0) + # Create templates: Monday 10:00 AM to 11:30 AM + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(11, 30), + ) + ] create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], + templates=templates, ) result = await availability_service.create_availability(create_request) assert result.user_id == volunteer_user.id - assert result.added == 3 # 10:00, 10:30, 11:00 - - # Verify time blocks were created - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 3 - start_times = {tb.start_time for tb in volunteer_user.availability} - assert start_time in start_times - assert start_time + timedelta(minutes=30) in start_times - assert start_time + timedelta(hours=1) in start_times - - -@pytest.mark.asyncio -async def test_create_availability_ignores_existing_blocks(db_session, volunteer_user): - """Test that creating availability ignores existing time blocks""" - availability_service = AvailabilityService(db_session) - - # Create an existing time block - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - existing_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - existing_block = TimeBlock(start_time=existing_time) - volunteer_user.availability.append(existing_block) - db_session.commit() - - # Try to create availability that includes the existing block - start_time = existing_time - end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + assert result.added == 3 # 10:00, 10:30, 11:00 (3 templates) - create_request = CreateAvailabilityRequest( - user_id=volunteer_user.id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], - ) - - result = await availability_service.create_availability(create_request) - - # Should only add 1 new block (10:30), not the existing 10:00 - assert result.added == 1 - - # Verify we still have 2 blocks total - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 2 + # Verify templates were created + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 3 + times = {t.start_time for t in templates} + assert dt_time(10, 0) in times + assert dt_time(10, 30) in times + assert dt_time(11, 0) in times + # All should be Monday (day_of_week 0) + assert all(t.day_of_week == 0 for t in templates) @pytest.mark.asyncio @@ -554,60 +530,65 @@ async def test_create_availability_multiple_ranges(db_session, volunteer_user): """Test creating availability with multiple time ranges""" availability_service = AvailabilityService(db_session) - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - - # Create two separate time ranges - range1_start = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - range1_end = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) - - range2_start = tomorrow.replace(hour=14, minute=0, second=0, microsecond=0) - range2_end = tomorrow.replace(hour=15, minute=0, second=0, microsecond=0) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(9, 0), + end_time=dt_time(10, 0), + ), + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + ), + ] create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, - available_times=[ - TimeRange(start_time=range1_start, end_time=range1_end), - TimeRange(start_time=range2_start, end_time=range2_end), - ], + templates=templates, ) result = await availability_service.create_availability(create_request) - # Should add 4 blocks total (2 from each range) + # Should add 4 templates total (2 from each range) assert result.added == 4 - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 4 + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 4 @pytest.mark.asyncio -async def test_delete_availability_removes_time_blocks(db_session, volunteer_user): - """Test that deleting availability removes time blocks correctly""" +async def test_delete_availability_removes_templates(db_session, volunteer_user): + """Test that deleting availability removes templates correctly""" availability_service = AvailabilityService(db_session) # First, create some availability - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=12, minute=0, second=0, microsecond=0) - - create_request = CreateAvailabilityRequest( - user_id=volunteer_user.id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, # Monday + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), # 10:00, 10:30, 11:00, 11:30 + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - await availability_service.create_availability(create_request) - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 4 # 10:00, 10:30, 11:00, 11:30 + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 4 # 10:00, 10:30, 11:00, 11:30 - # Now delete a portion of it (10:00 to 11:00, should remove 2 blocks) - delete_start = start_time - delete_end = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + # Now delete a portion of it (10:00 to 11:00, should remove 2 templates) + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] delete_request = DeleteAvailabilityRequest( user_id=volunteer_user.id, - delete=[TimeRange(start_time=delete_start, end_time=delete_end)], + templates=delete_templates, ) result = await availability_service.delete_availability(delete_request) @@ -615,51 +596,60 @@ async def test_delete_availability_removes_time_blocks(db_session, volunteer_use assert result.user_id == volunteer_user.id assert result.deleted == 2 # Removed 10:00 and 10:30 - # Verify remaining blocks - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 2 # Should have 11:00 and 11:30 left - remaining_times = {tb.start_time for tb in volunteer_user.availability} - assert tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) in remaining_times - assert tomorrow.replace(hour=11, minute=30, second=0, microsecond=0) in remaining_times + # Verify remaining templates + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 2 # Should have 11:00 and 11:30 left + times = {t.start_time for t in remaining} + assert dt_time(11, 0) in times + assert dt_time(11, 30) in times @pytest.mark.asyncio -async def test_delete_availability_ignores_non_existent_blocks(db_session, volunteer_user): - """Test that deleting availability ignores non-existent time blocks""" +async def test_delete_availability_ignores_non_existent(db_session, volunteer_user): + """Test that deleting availability ignores non-existent templates""" availability_service = AvailabilityService(db_session) # Create some availability - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) - - create_request = CreateAvailabilityRequest( - user_id=volunteer_user.id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - await availability_service.create_availability(create_request) - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 2 + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 2 - # Try to delete a time range that doesn't exist (14:00 to 15:00) - delete_start = tomorrow.replace(hour=14, minute=0, second=0, microsecond=0) - delete_end = tomorrow.replace(hour=15, minute=0, second=0, microsecond=0) + # Try to delete templates that don't exist (Tuesday 14:00 to 15:00) + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=1, # Tuesday (doesn't exist) + start_time=dt_time(14, 0), + end_time=dt_time(15, 0), + ) + ] delete_request = DeleteAvailabilityRequest( user_id=volunteer_user.id, - delete=[TimeRange(start_time=delete_start, end_time=delete_end)], + templates=delete_templates, ) result = await availability_service.delete_availability(delete_request) - # Should delete 0 blocks since none exist in that range + # Should delete 0 templates since none exist assert result.deleted == 0 - # Verify original blocks are still there - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 2 + # Verify original templates are still there + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 2 @pytest.mark.asyncio @@ -668,34 +658,42 @@ async def test_delete_all_availability(db_session, volunteer_user): availability_service = AvailabilityService(db_session) # Create availability - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=12, minute=0, second=0, microsecond=0) - - create_request = CreateAvailabilityRequest( - user_id=volunteer_user.id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), + ) + ] + await availability_service.create_availability( + CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - await availability_service.create_availability(create_request) - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 4 + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() + assert len(templates) == 4 # Delete all availability + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(12, 0), + ) + ] delete_request = DeleteAvailabilityRequest( user_id=volunteer_user.id, - delete=[TimeRange(start_time=start_time, end_time=end_time)], + templates=delete_templates, ) result = await availability_service.delete_availability(delete_request) assert result.deleted == 4 - # Verify all blocks are removed - db_session.refresh(volunteer_user) - assert len(volunteer_user.availability) == 0 - # Note: result.availability might contain stale data before refresh, so we check the refreshed user instead + # Verify all templates are removed + remaining = db_session.query(AvailabilityTemplate).filter_by( + user_id=volunteer_user.id, is_active=True + ).all() + assert len(remaining) == 0 @pytest.mark.asyncio @@ -704,14 +702,17 @@ async def test_delete_availability_user_not_found(db_session): availability_service = AvailabilityService(db_session) fake_user_id = uuid4() - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + delete_templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] delete_request = DeleteAvailabilityRequest( user_id=fake_user_id, - delete=[TimeRange(start_time=start_time, end_time=end_time)], + templates=delete_templates, ) with pytest.raises(Exception) as exc_info: @@ -727,14 +728,17 @@ async def test_create_availability_user_not_found(db_session): availability_service = AvailabilityService(db_session) fake_user_id = uuid4() - now = datetime.now(timezone.utc) - tomorrow = now + timedelta(days=1) - start_time = tomorrow.replace(hour=10, minute=0, second=0, microsecond=0) - end_time = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0) + templates = [ + AvailabilityTemplateSlot( + day_of_week=0, + start_time=dt_time(10, 0), + end_time=dt_time(11, 0), + ) + ] create_request = CreateAvailabilityRequest( user_id=fake_user_id, - available_times=[TimeRange(start_time=start_time, end_time=end_time)], + templates=templates, ) with pytest.raises(Exception) as exc_info: diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 09220729..71b098d1 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -505,27 +505,34 @@ export const updateUserData = async ( /** * Availability API types and functions */ -export interface TimeRange { - startTime: string; // ISO datetime string - endTime: string; // ISO datetime string +export interface AvailabilityTemplate { + dayOfWeek: number; // 0=Monday, 1=Tuesday, ..., 6=Sunday + startTime: string; // Time string in format "HH:MM:SS" or "HH:MM" + endTime: string; // Time string in format "HH:MM:SS" or "HH:MM" } export interface CreateAvailabilityRequest { userId: string; - availableTimes: TimeRange[]; + templates: AvailabilityTemplate[]; } export interface DeleteAvailabilityRequest { userId: string; - delete: TimeRange[]; + templates: AvailabilityTemplate[]; } /** * Get availability for a user */ -export const getAvailability = async (userId: string): Promise<{ availableTimes: Array<{ id: number; startTime: string }> }> => { - const response = await baseAPIClient.get<{ userId: string; availableTimes: Array<{ id: number; startTime: string }> }>(`/availability?user_id=${userId}`); - return response.data; +export const getAvailability = async (userId: string): Promise<{ templates: AvailabilityTemplate[] }> => { + const response = await baseAPIClient.get<{ user_id: string; templates: Array<{ day_of_week: number; start_time: string; end_time: string }> }>(`/availability?user_id=${userId}`); + return { + templates: response.data.templates.map(t => ({ + dayOfWeek: t.day_of_week, + startTime: t.start_time, + endTime: t.end_time, + })), + }; }; /** @@ -535,9 +542,10 @@ export const createAvailability = async (request: CreateAvailabilityRequest): Pr // Convert camelCase to snake_case for backend const backendData = { user_id: request.userId, - available_times: request.availableTimes.map(range => ({ - start_time: range.startTime, - end_time: range.endTime, + templates: request.templates.map(template => ({ + day_of_week: template.dayOfWeek, + start_time: template.startTime, + end_time: template.endTime, })), }; const response = await baseAPIClient.post<{ user_id: string; added: number }>('/availability', backendData); @@ -547,19 +555,24 @@ export const createAvailability = async (request: CreateAvailabilityRequest): Pr /** * Delete availability for a user */ -export const deleteAvailability = async (request: DeleteAvailabilityRequest): Promise<{ userId: string; deleted: number; availability: Array<{ id: number; startTime: string }> }> => { +export const deleteAvailability = async (request: DeleteAvailabilityRequest): Promise<{ userId: string; deleted: number; templates: AvailabilityTemplate[] }> => { // Convert camelCase to snake_case for backend const backendData = { user_id: request.userId, - delete: request.delete.map(range => ({ - start_time: range.startTime, - end_time: range.endTime, + templates: request.templates.map(template => ({ + day_of_week: template.dayOfWeek, + start_time: template.startTime, + end_time: template.endTime, })), }; - const response = await baseAPIClient.delete<{ user_id: string; deleted: number; availability: Array<{ id: number; start_time: string }> }>('/availability', { data: backendData }); + const response = await baseAPIClient.delete<{ user_id: string; deleted: number; templates: Array<{ day_of_week: number; start_time: string; end_time: string }> }>('/availability', { data: backendData }); return { userId: response.data.user_id, deleted: response.data.deleted, - availability: response.data.availability.map(block => ({ id: block.id, startTime: block.start_time })), + templates: response.data.templates.map(t => ({ + dayOfWeek: t.day_of_week, + startTime: t.start_time, + endTime: t.end_time, + })), }; }; diff --git a/frontend/src/components/admin/userProfile/AvailabilitySection.tsx b/frontend/src/components/admin/userProfile/AvailabilitySection.tsx index 7bc6dc34..cc980b50 100644 --- a/frontend/src/components/admin/userProfile/AvailabilitySection.tsx +++ b/frontend/src/components/admin/userProfile/AvailabilitySection.tsx @@ -161,21 +161,28 @@ export function AvailabilitySection({ // In edit mode, check selectedTimeSlots isAvailable = selectedTimeSlots.has(slotKey); } else { - // In view mode, check user.availability - isAvailable = user.availability?.some(block => { - const date = new Date(block.startTime); - const jsDay = date.getDay(); // 0=Sun, 1=Mon... - const gridDay = jsDay === 0 ? 6 : jsDay - 1; + // In view mode, check user.availability templates + isAvailable = user.availability?.some(template => { + // Parse time strings (format: "HH:MM:SS" or "HH:MM") + const parseTime = (timeStr: string): { hour: number; minute: number } => { + const parts = timeStr.split(':'); + return { + hour: parseInt(parts[0], 10), + minute: parseInt(parts[1], 10), + }; + }; - const hour = date.getHours(); - const minute = date.getMinutes(); + const startTime = parseTime(template.startTime); + const endTime = parseTime(template.endTime); - // Calculate target hour and minute based on timeIndex - // timeIndex 0 -> 8:00, 1 -> 8:30, 2 -> 9:00... - const targetHour = 8 + Math.floor(timeIndex / 2); - const targetMinute = (timeIndex % 2) * 30; + // Calculate time indices + const startTimeIndex = (startTime.hour - 8) * 2 + (startTime.minute === 30 ? 1 : 0); + const endTimeIndex = (endTime.hour - 8) * 2 + (endTime.minute === 30 ? 1 : 0); - return gridDay === dayIndex && hour === targetHour && minute === targetMinute; + // Check if this slot is within the template's range + return template.dayOfWeek === dayIndex && + timeIndex >= startTimeIndex && + timeIndex < endTimeIndex; }) || false; } @@ -228,62 +235,80 @@ export function AvailabilitySection({ Your Availability {['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, index) => { - // Filter blocks for this day - // Note: getDay() returns 0 for Sunday, 1 for Monday, etc. - // Our map index 0 is Monday, so we need to match correctly. - // Monday (index 0) -> getDay() 1 - // ... - // Saturday (index 5) -> getDay() 6 - // Sunday (index 6) -> getDay() 0 - const targetDay = index === 6 ? 0 : index + 1; - - const dayBlocks = user.availability?.filter(block => { - const date = new Date(block.startTime); - return date.getDay() === targetDay; - }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + // Filter templates for this day (index 0=Monday, 6=Sunday) + const dayTemplates = user.availability?.filter(template => { + return template.dayOfWeek === index; + }) || []; - if (!dayBlocks || dayBlocks.length === 0) { + if (dayTemplates.length === 0) { return null; } - // Group contiguous blocks into ranges - const ranges: { start: Date; end: Date }[] = []; - if (dayBlocks.length > 0) { - let currentStart = new Date(dayBlocks[0].startTime); - let currentEnd = new Date(dayBlocks[0].startTime); - currentEnd.setMinutes(currentEnd.getMinutes() + 30); // Each block is 30 mins + // Parse time string to minutes since midnight + const parseTimeToMinutes = (timeStr: string): number => { + const parts = timeStr.split(':'); + const hour = parseInt(parts[0], 10); + const minute = parseInt(parts[1], 10); + return hour * 60 + minute; + }; - for (let i = 1; i < dayBlocks.length; i++) { - const nextBlockStart = new Date(dayBlocks[i].startTime); - if (nextBlockStart.getTime() === currentEnd.getTime()) { - // Contiguous, extend current range - currentEnd.setMinutes(currentEnd.getMinutes() + 30); + // Convert minutes since midnight back to time string + const minutesToTimeString = (minutes: number): string => { + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + const date = new Date(); + date.setHours(hour, minute, 0); + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + }; + + // Expand templates into 30-minute blocks + const blocks: number[] = []; + dayTemplates.forEach(template => { + const startMinutes = parseTimeToMinutes(template.startTime); + const endMinutes = parseTimeToMinutes(template.endTime); + + // Add each 30-minute block + for (let minutes = startMinutes; minutes < endMinutes; minutes += 30) { + if (!blocks.includes(minutes)) { + blocks.push(minutes); + } + } + }); + + // Sort blocks by time + blocks.sort((a, b) => a - b); + + // Group consecutive blocks into ranges + const ranges: { start: number; end: number }[] = []; + if (blocks.length > 0) { + let rangeStart = blocks[0]; + let rangeEnd = blocks[0] + 30; // Each block is 30 minutes + + for (let i = 1; i < blocks.length; i++) { + const currentBlock = blocks[i]; + // If this block is contiguous with the current range, extend it + if (currentBlock === rangeEnd) { + rangeEnd = currentBlock + 30; } else { - // Gap found, push current range and start new one - ranges.push({ start: currentStart, end: currentEnd }); - currentStart = nextBlockStart; - currentEnd = new Date(nextBlockStart); - currentEnd.setMinutes(currentEnd.getMinutes() + 30); + // Gap found, save current range and start new one + ranges.push({ start: rangeStart, end: rangeEnd }); + rangeStart = currentBlock; + rangeEnd = currentBlock + 30; } } - ranges.push({ start: currentStart, end: currentEnd }); + // Don't forget the last range + ranges.push({ start: rangeStart, end: rangeEnd }); } return ( {day}: - {ranges.map((range, i) => { - // Format time: 12:00 PM - 4:00 PM - const formatTime = (date: Date) => { - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); - }; - return ( - - {formatTime(range.start)} - {formatTime(range.end)} - - ); - })} + {ranges.map((range, i) => ( + + {minutesToTimeString(range.start)} - {minutesToTimeString(range.end)} + + ))} ); diff --git a/frontend/src/hooks/useAvailabilityEditing.ts b/frontend/src/hooks/useAvailabilityEditing.ts index e58a83ca..d58f72b2 100644 --- a/frontend/src/hooks/useAvailabilityEditing.ts +++ b/frontend/src/hooks/useAvailabilityEditing.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { getUserById, createAvailability, deleteAvailability, TimeRange } from '@/APIClients/authAPIClient'; +import { getUserById, createAvailability, deleteAvailability, AvailabilityTemplate } from '@/APIClients/authAPIClient'; import { UserResponse } from '@/types/userTypes'; import { SaveMessage } from '@/types/userProfileTypes'; @@ -68,17 +68,31 @@ export function useAvailabilityEditing({ const handleStartEditAvailability = () => { const slots = new Set(); if (user?.availability) { - user.availability.forEach(block => { - const date = new Date(block.startTime); - const jsDay = date.getDay(); - const gridDay = jsDay === 0 ? 6 : jsDay - 1; + // Convert availability templates to grid slots + user.availability.forEach(template => { + const dayOfWeek = template.dayOfWeek; // Already 0=Mon, 6=Sun - const hour = date.getHours(); - const minute = date.getMinutes(); - const timeIndex = (hour - 8) * 2 + (minute === 30 ? 1 : 0); + // Parse start and end times (format: "HH:MM:SS" or "HH:MM") + const parseTime = (timeStr: string): { hour: number; minute: number } => { + const parts = timeStr.split(':'); + return { + hour: parseInt(parts[0], 10), + minute: parseInt(parts[1], 10), + }; + }; - if (timeIndex >= 0 && timeIndex < 48) { - slots.add(`${gridDay}-${timeIndex}`); + const startTime = parseTime(template.startTime); + const endTime = parseTime(template.endTime); + + // Calculate time indices + const startTimeIndex = (startTime.hour - 8) * 2 + (startTime.minute === 30 ? 1 : 0); + const endTimeIndex = (endTime.hour - 8) * 2 + (endTime.minute === 30 ? 1 : 0); + + // Add all slots in the range + for (let timeIndex = startTimeIndex; timeIndex < endTimeIndex; timeIndex++) { + if (timeIndex >= 0 && timeIndex < 48) { + slots.add(`${dayOfWeek}-${timeIndex}`); + } } }); } @@ -154,9 +168,11 @@ export function useAvailabilityEditing({ return rangeSlots; }; - const convertSlotsToTimeRanges = (): TimeRange[] => { - const referenceMonday = new Date('2000-01-03T00:00:00'); - const ranges: TimeRange[] = []; + /** + * Convert selected grid slots to availability templates (day_of_week + time ranges) + */ + const convertSlotsToTemplates = (): AvailabilityTemplate[] => { + const templates: AvailabilityTemplate[] = []; const slots = Array.from(selectedTimeSlots).map(key => { const [dayIndex, timeIndex] = key.split('-').map(Number); return { dayIndex, timeIndex }; @@ -165,26 +181,26 @@ export function useAvailabilityEditing({ return a.timeIndex - b.timeIndex; }); - interface TimeRangeSlot { + interface TemplateSlot { dayIndex: number; startTimeIndex: number; endTimeIndex: number; } - let currentRange: TimeRangeSlot | null = null; + let currentRange: TemplateSlot | null = null; slots.forEach(({ dayIndex, timeIndex }) => { if (!currentRange || currentRange.dayIndex !== dayIndex || currentRange.endTimeIndex !== timeIndex - 1) { if (currentRange) { - const startDate = new Date(referenceMonday); - startDate.setDate(referenceMonday.getDate() + currentRange.dayIndex); - startDate.setHours(8 + Math.floor(currentRange.startTimeIndex / 2), (currentRange.startTimeIndex % 2) * 30, 0, 0); + // Convert timeIndex to hours and minutes + const startHour = 8 + Math.floor(currentRange.startTimeIndex / 2); + const startMinute = (currentRange.startTimeIndex % 2) * 30; + const endHour = 8 + Math.floor((currentRange.endTimeIndex + 1) / 2); + const endMinute = ((currentRange.endTimeIndex + 1) % 2) * 30; - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + 30 * (currentRange.endTimeIndex - currentRange.startTimeIndex + 1)); - - ranges.push({ - startTime: startDate.toISOString(), - endTime: endDate.toISOString(), + templates.push({ + dayOfWeek: currentRange.dayIndex, + startTime: `${startHour.toString().padStart(2, '0')}:${startMinute.toString().padStart(2, '0')}:00`, + endTime: `${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}:00`, }); } currentRange = { dayIndex, startTimeIndex: timeIndex, endTimeIndex: timeIndex }; @@ -196,21 +212,20 @@ export function useAvailabilityEditing({ }); if (currentRange !== null) { - const range: TimeRangeSlot = currentRange; - const startDate = new Date(referenceMonday); - startDate.setDate(referenceMonday.getDate() + range.dayIndex); - startDate.setHours(8 + Math.floor(range.startTimeIndex / 2), (range.startTimeIndex % 2) * 30, 0, 0); - - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + 30 * (range.endTimeIndex - range.startTimeIndex + 1)); + const range: TemplateSlot = currentRange; + const startHour = 8 + Math.floor(range.startTimeIndex / 2); + const startMinute = (range.startTimeIndex % 2) * 30; + const endHour = 8 + Math.floor((range.endTimeIndex + 1) / 2); + const endMinute = ((range.endTimeIndex + 1) % 2) * 30; - ranges.push({ - startTime: startDate.toISOString(), - endTime: endDate.toISOString(), + templates.push({ + dayOfWeek: range.dayIndex, + startTime: `${startHour.toString().padStart(2, '0')}:${startMinute.toString().padStart(2, '0')}:00`, + endTime: `${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}:00`, }); } - return ranges; + return templates; }; const handleSaveAvailability = async () => { @@ -218,61 +233,13 @@ export function useAvailabilityEditing({ setIsSaving(true); try { - const existingRanges: TimeRange[] = []; - if (user.availability && user.availability.length > 0) { - const sortedBlocks = [...user.availability].sort((a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - - let currentStart: Date | null = null; - let currentEnd: Date | null = null; - - sortedBlocks.forEach(block => { - const blockStart = new Date(block.startTime); - const blockEnd = new Date(blockStart); - blockEnd.setMinutes(blockEnd.getMinutes() + 30); - - if (!currentStart) { - currentStart = blockStart; - currentEnd = blockEnd; - } else if (currentEnd && blockStart.getTime() === currentEnd.getTime()) { - currentEnd = blockEnd; - } else { - if (currentStart && currentEnd) { - existingRanges.push({ - startTime: currentStart.toISOString(), - endTime: currentEnd.toISOString(), - }); - } - currentStart = blockStart; - currentEnd = blockEnd; - } - }); - - if (currentStart !== null && currentEnd !== null) { - const start: Date = currentStart; - const end: Date = currentEnd; - existingRanges.push({ - startTime: start.toISOString(), - endTime: end.toISOString(), - }); - } - } - - if (existingRanges.length > 0) { - await deleteAvailability({ - userId: userId as string, - delete: existingRanges, - }); - } - - const newRanges = convertSlotsToTimeRanges(); - if (newRanges.length > 0) { - await createAvailability({ - userId: userId as string, - availableTimes: newRanges, - }); - } + // Convert selected slots to templates and create them + // Backend create_availability replaces all existing templates, so we don't need to delete separately + const newTemplates = convertSlotsToTemplates(); + await createAvailability({ + userId: userId as string, + templates: newTemplates, + }); const updatedUser = await getUserById(userId as string); setUser(updatedUser); @@ -309,4 +276,3 @@ export function useAvailabilityEditing({ handleCancelEditAvailability, }; } - diff --git a/frontend/src/types/userTypes.ts b/frontend/src/types/userTypes.ts index a7ced891..2705eb5b 100644 --- a/frontend/src/types/userTypes.ts +++ b/frontend/src/types/userTypes.ts @@ -58,6 +58,12 @@ export interface TimeBlock { startTime: string; } +export interface AvailabilityTemplate { + dayOfWeek: number; // 0=Monday, 1=Tuesday, ..., 6=Sunday + startTime: string; // Time string in format "HH:MM:SS" + endTime: string; // Time string in format "HH:MM:SS" +} + export interface UserResponse { id: string; firstName: string | null; @@ -73,6 +79,6 @@ export interface UserResponse { }; userData?: UserData | null; volunteerData?: VolunteerData | null; - availability?: TimeBlock[]; + availability?: AvailabilityTemplate[]; } From 7573f0c1ff93901f86827d7d3ba78942840ef082 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 20 Nov 2025 15:49:58 -0500 Subject: [PATCH 4/9] lint --- backend/app/models/AvailabilityTemplate.py | 7 +- backend/app/schemas/availability.py | 7 +- backend/app/schemas/user.py | 3 +- backend/app/schemas/user_data.py | 15 +- backend/app/seeds/users.py | 56 +-- .../implementations/availability_service.py | 97 ++--- .../services/implementations/match_service.py | 61 +-- .../services/implementations/user_service.py | 192 ++++----- backend/app/utilities/timezone_utils.py | 21 +- ...1638c9_add_availability_templates_table.py | 33 +- ...dda4b46776e9_drop_available_times_table.py | 19 +- .../tests/unit/test_availability_service.py | 160 ++++---- backend/tests/unit/test_match_service.py | 32 +- .../tests/unit/test_match_service_timezone.py | 109 +++-- backend/tests/unit/test_user_data_update.py | 133 +++--- frontend/src/APIClients/authAPIClient.ts | 82 ++-- frontend/src/components/admin/AdminHeader.tsx | 12 +- .../admin/userProfile/AvailabilitySection.tsx | 382 +++++++++++------- .../userProfile/CancerExperienceSection.tsx | 211 ++++++---- .../admin/userProfile/LovedOneSection.tsx | 246 +++++++---- .../admin/userProfile/ProfileContent.tsx | 65 ++- .../admin/userProfile/ProfileNavigation.tsx | 33 +- .../admin/userProfile/ProfileSummary.tsx | 156 ++++--- .../admin/userProfile/SuccessMessage.tsx | 7 +- frontend/src/hooks/useAvailabilityEditing.ts | 51 ++- frontend/src/hooks/useIntakeOptions.ts | 11 +- frontend/src/hooks/useProfileEditing.ts | 57 ++- frontend/src/hooks/useUserProfile.ts | 1 - frontend/src/pages/admin/users/[id].tsx | 140 ++++--- frontend/src/types/userProfileTypes.ts | 1 - frontend/src/types/userTypes.ts | 3 +- frontend/src/utils/userProfileUtils.ts | 3 +- 32 files changed, 1332 insertions(+), 1074 deletions(-) diff --git a/backend/app/models/AvailabilityTemplate.py b/backend/app/models/AvailabilityTemplate.py index 7425eed6..08737cbf 100644 --- a/backend/app/models/AvailabilityTemplate.py +++ b/backend/app/models/AvailabilityTemplate.py @@ -1,8 +1,7 @@ -from sqlalchemy import Boolean, Column, ForeignKey, Integer, Time +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Time from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.sql import func -from sqlalchemy import DateTime from .Base import Base @@ -13,6 +12,7 @@ class AvailabilityTemplate(Base): Each template represents a time slot on a specific day of the week. These templates are projected forward to create specific TimeBlocks for matches. """ + __tablename__ = "availability_templates" id = Column(Integer, primary_key=True) @@ -23,7 +23,7 @@ class AvailabilityTemplate(Base): # Time of day (just time, no date) start_time = Column(Time, nullable=False) # e.g., 14:00:00 - end_time = Column(Time, nullable=False) # e.g., 16:00:00 + end_time = Column(Time, nullable=False) # e.g., 16:00:00 # Optional: for future enhancements (e.g., temporarily disable a template) is_active = Column(Boolean, default=True) @@ -32,4 +32,3 @@ class AvailabilityTemplate(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) user = relationship("User", back_populates="availability_templates") - diff --git a/backend/app/schemas/availability.py b/backend/app/schemas/availability.py index 590a537a..f6d77ae5 100644 --- a/backend/app/schemas/availability.py +++ b/backend/app/schemas/availability.py @@ -1,17 +1,16 @@ +from datetime import time from typing import List from uuid import UUID -from datetime import time from pydantic import BaseModel -from app.schemas.time_block import TimeBlockEntity - class AvailabilityTemplateSlot(BaseModel): """Represents a single availability template slot (day of week + time range)""" + day_of_week: int # 0=Monday, 1=Tuesday, ..., 6=Sunday start_time: time # e.g., 14:00:00 - end_time: time # e.g., 16:00:00 + end_time: time # e.g., 16:00:00 class CreateAvailabilityRequest(BaseModel): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 39d4c28a..d92c1785 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -9,10 +9,9 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -from .time_block import TimeBlockEntity +from .availability import AvailabilityTemplateSlot from .user_data import UserDataResponse from .volunteer_data import VolunteerDataResponse -from .availability import AvailabilityTemplateSlot # TODO: # confirm complexity rules for fields (such as password) diff --git a/backend/app/schemas/user_data.py b/backend/app/schemas/user_data.py index 873e7842..da961e3e 100644 --- a/backend/app/schemas/user_data.py +++ b/backend/app/schemas/user_data.py @@ -22,7 +22,7 @@ class ExperienceResponse(BaseModel): class UserDataResponse(BaseModel): id: UUID user_id: UUID - + # Personal Information first_name: Optional[str] last_name: Optional[str] @@ -44,7 +44,7 @@ class UserDataResponse(BaseModel): # Cancer Experience diagnosis: Optional[str] date_of_diagnosis: Optional[date] - + # Custom entries other_ethnic_group: Optional[str] gender_identity_custom: Optional[str] @@ -74,7 +74,7 @@ class UserDataUpdateRequest(BaseModel): Request schema for user_data updates, all fields optional. Supports partial updates for user's own data and loved one's data. """ - + # Personal Information first_name: Optional[str] = None last_name: Optional[str] = None @@ -83,7 +83,7 @@ class UserDataUpdateRequest(BaseModel): city: Optional[str] = None province: Optional[str] = None postal_code: Optional[str] = None - + # Demographics gender_identity: Optional[str] = None pronouns: Optional[List[str]] = Field(None, description="List of pronoun strings") @@ -91,18 +91,18 @@ class UserDataUpdateRequest(BaseModel): marital_status: Optional[str] = None has_kids: Optional[str] = None timezone: Optional[str] = None - + # User's Cancer Experience diagnosis: Optional[str] = None date_of_diagnosis: Optional[date] = None treatments: Optional[List[str]] = Field(None, description="List of treatment names") experiences: Optional[List[str]] = Field(None, description="List of experience names") additional_info: Optional[str] = None - + # Loved One Demographics loved_one_gender_identity: Optional[str] = None loved_one_age: Optional[str] = None - + # Loved One's Cancer Experience loved_one_diagnosis: Optional[str] = None loved_one_date_of_diagnosis: Optional[date] = None @@ -110,4 +110,3 @@ class UserDataUpdateRequest(BaseModel): loved_one_experiences: Optional[List[str]] = Field(None, description="List of experience names") model_config = ConfigDict(from_attributes=True) - diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 14088371..b2df082c 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -3,21 +3,21 @@ import uuid from datetime import date +from sqlalchemy import delete from sqlalchemy.orm import Session +from app.models.AvailabilityTemplate import AvailabilityTemplate from app.models.Experience import Experience +from app.models.FormSubmission import FormSubmission +from app.models.Match import Match +from app.models.RankingPreference import RankingPreference +from app.models.SuggestedTime import suggested_times +from app.models.Task import Task from app.models.Treatment import Treatment from app.models.User import FormStatus, User from app.models.UserData import UserData from app.models.VolunteerData import VolunteerData -from app.models.RankingPreference import RankingPreference -from app.models.Match import Match -from app.models.FormSubmission import FormSubmission -from app.models.Task import Task -from app.models.SuggestedTime import suggested_times -from app.models.AvailabilityTemplate import AvailabilityTemplate from app.utilities.form_constants import ExperienceId, TreatmentId -from sqlalchemy import delete def seed_users(session: Session) -> None: @@ -317,37 +317,33 @@ def seed_users(session: Session) -> None: if existing_user: print(f"User already exists, overwriting: {user_info['user_data']['email']}") user_id = existing_user.id - + # Manually delete all related data first (since cascade delete may not be configured) # Delete ranking preferences session.query(RankingPreference).filter(RankingPreference.user_id == user_id).delete() - + # Get matches that need to be deleted (to delete suggested_times first) - matches_to_delete = session.query(Match).filter( - (Match.participant_id == user_id) | (Match.volunteer_id == user_id) - ).all() - + matches_to_delete = ( + session.query(Match).filter((Match.participant_id == user_id) | (Match.volunteer_id == user_id)).all() + ) + # Delete suggested_times for these matches first (must be done before deleting matches) # Use raw SQL to delete from suggested_times table to avoid relationship issues match_ids = [match.id for match in matches_to_delete] if match_ids: - session.execute( - delete(suggested_times).where(suggested_times.c.match_id.in_(match_ids)) - ) + session.execute(delete(suggested_times).where(suggested_times.c.match_id.in_(match_ids))) session.flush() # Ensure suggested_times deletions are processed - + # Now delete the matches (after suggested_times are cleared) for match in matches_to_delete: session.delete(match) - + # Delete form submissions session.query(FormSubmission).filter(FormSubmission.user_id == user_id).delete() - + # Delete tasks (as participant or assignee) - session.query(Task).filter( - (Task.participant_id == user_id) | (Task.assignee_id == user_id) - ).delete() - + session.query(Task).filter((Task.participant_id == user_id) | (Task.assignee_id == user_id)).delete() + # Delete user_data and its relationships if existing_user.user_data: # Clear many-to-many relationships first @@ -356,14 +352,14 @@ def seed_users(session: Session) -> None: existing_user.user_data.loved_one_treatments.clear() existing_user.user_data.loved_one_experiences.clear() session.delete(existing_user.user_data) - + # Delete volunteer_data if existing_user.volunteer_data: session.delete(existing_user.volunteer_data) - + # Clear availability templates session.query(AvailabilityTemplate).filter_by(user_id=existing_user.id).delete() - + # Now delete the user session.delete(existing_user) session.flush() # Ensure deletion is processed before creating new user @@ -409,12 +405,16 @@ def seed_users(session: Session) -> None: # Add loved one treatments if they exist if user_info.get("loved_one_treatments"): - loved_one_treatments = session.query(Treatment).filter(Treatment.id.in_(user_info["loved_one_treatments"])).all() + loved_one_treatments = ( + session.query(Treatment).filter(Treatment.id.in_(user_info["loved_one_treatments"])).all() + ) user_data.loved_one_treatments = loved_one_treatments # Add loved one experiences if they exist if user_info.get("loved_one_experiences"): - loved_one_experiences = session.query(Experience).filter(Experience.id.in_(user_info["loved_one_experiences"])).all() + loved_one_experiences = ( + session.query(Experience).filter(Experience.id.in_(user_info["loved_one_experiences"])).all() + ) user_data.loved_one_experiences = loved_one_experiences # Create volunteer_data entry for volunteers with experience text diff --git a/backend/app/services/implementations/availability_service.py b/backend/app/services/implementations/availability_service.py index 4f5a7bc7..0ce1a242 100644 --- a/backend/app/services/implementations/availability_service.py +++ b/backend/app/services/implementations/availability_service.py @@ -1,5 +1,5 @@ import logging -from datetime import timedelta, time as dt_time +from datetime import time as dt_time from typing import List from fastapi import HTTPException @@ -28,25 +28,22 @@ async def get_availability(self, req: GetAvailabilityRequest) -> AvailabilityEnt """ try: user_id = req.user_id - user = self.db.query(User).filter_by(id=user_id).one() - + # Verify user exists + self.db.query(User).filter_by(id=user_id).one() + # Get templates - templates = self.db.query(AvailabilityTemplate).filter_by( - user_id=user_id, is_active=True - ).all() - + templates = self.db.query(AvailabilityTemplate).filter_by(user_id=user_id, is_active=True).all() + # Convert to response format template_slots: List[AvailabilityTemplateSlot] = [] for template in templates: - template_slots.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - - validated_data = AvailabilityEntity.model_validate( - {"user_id": user_id, "templates": template_slots} - ) + template_slots.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, start_time=template.start_time, end_time=template.end_time + ) + ) + + validated_data = AvailabilityEntity.model_validate({"user_id": user_id, "templates": template_slots}) return validated_data except Exception as e: @@ -62,53 +59,51 @@ async def create_availability(self, availability: CreateAvailabilityRequest) -> added = 0 try: user_id = availability.user_id - user = self.db.query(User).filter_by(id=user_id).one() - + # Verify user exists + self.db.query(User).filter_by(id=user_id).one() + # Delete all existing templates for this user self.db.query(AvailabilityTemplate).filter_by(user_id=user_id).delete() - + # Track templates we've seen to avoid duplicates seen_templates = set() - + for template_slot in availability.templates: # Validate day_of_week if not (0 <= template_slot.day_of_week <= 6): raise HTTPException( status_code=400, - detail=f"Invalid day_of_week: {template_slot.day_of_week}. Must be 0-6 (Monday-Sunday)" + detail=f"Invalid day_of_week: {template_slot.day_of_week}. Must be 0-6 (Monday-Sunday)", ) - + # Validate time range if template_slot.end_time <= template_slot.start_time: - raise HTTPException( - status_code=400, - detail=f"end_time must be after start_time" - ) - + raise HTTPException(status_code=400, detail="end_time must be after start_time") + # Create template for each 30-minute block in the range current_time = template_slot.start_time end_time = template_slot.end_time - + while current_time < end_time: # Calculate next 30-minute increment next_time = self._add_minutes(current_time, 30) if next_time > end_time: next_time = end_time - + template_key = (template_slot.day_of_week, current_time) - + if template_key not in seen_templates: template = AvailabilityTemplate( user_id=user_id, day_of_week=template_slot.day_of_week, start_time=current_time, end_time=next_time, - is_active=True + is_active=True, ) self.db.add(template) seen_templates.add(template_key) added += 1 - + current_time = next_time self.db.flush() @@ -133,53 +128,47 @@ async def delete_availability(self, req: DeleteAvailabilityRequest) -> DeleteAva try: user_id = req.user_id - user = self.db.query(User).filter(User.id == user_id).one() - + # Verify user exists + self.db.query(User).filter(User.id == user_id).one() + # Collect templates to delete templates_to_delete = set() - + for template_slot in req.templates: # Validate day_of_week if not (0 <= template_slot.day_of_week <= 6): self.logger.warning(f"Skipping invalid day_of_week: {template_slot.day_of_week}") continue - + # Find all templates in this range current_time = template_slot.start_time end_time = template_slot.end_time - + while current_time < end_time: templates_to_delete.add((template_slot.day_of_week, current_time)) current_time = self._add_minutes(current_time, 30) - + # Delete matching templates for day_of_week, time_val in templates_to_delete: deleted_count = ( self.db.query(AvailabilityTemplate) - .filter_by( - user_id=user_id, - day_of_week=day_of_week, - start_time=time_val, - is_active=True - ) + .filter_by(user_id=user_id, day_of_week=day_of_week, start_time=time_val, is_active=True) .delete() ) deleted += deleted_count self.db.flush() - + # Get remaining templates for response - templates = self.db.query(AvailabilityTemplate).filter_by( - user_id=user_id, is_active=True - ).all() - + templates = self.db.query(AvailabilityTemplate).filter_by(user_id=user_id, is_active=True).all() + remaining_slots: List[AvailabilityTemplateSlot] = [] for template in templates: - remaining_slots.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) + remaining_slots.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, start_time=template.start_time, end_time=template.end_time + ) + ) response = DeleteAvailabilityResponse.model_validate( {"user_id": req.user_id, "deleted": deleted, "templates": remaining_slots} diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index 3ee550fb..9571ca84 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -9,7 +9,6 @@ from app.models import AvailabilityTemplate, Match, MatchStatus, TimeBlock, User from app.models.UserData import UserData -from app.utilities.timezone_utils import get_timezone_from_abbreviation from app.schemas.match import ( MatchCreateRequest, MatchCreateResponse, @@ -25,6 +24,7 @@ ) from app.schemas.time_block import TimeBlockEntity, TimeRange from app.schemas.user import UserRole +from app.utilities.timezone_utils import get_timezone_from_abbreviation SCHEDULE_CLEANUP_STATUSES = { "pending", @@ -65,10 +65,7 @@ async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse: for volunteer_id in req.volunteer_ids: volunteer: User | None = ( - self.db.query(User) - .options(joinedload(User.user_data)) - .filter(User.id == volunteer_id) - .first() + self.db.query(User).options(joinedload(User.user_data)).filter(User.id == volunteer_id).first() ) if not volunteer: raise HTTPException(404, f"Volunteer {volunteer_id} not found") @@ -118,10 +115,7 @@ async def update_match(self, match_id: int, req: MatchUpdateRequest) -> MatchRes volunteer_changed = False if req.volunteer_id is not None and req.volunteer_id != match.volunteer_id: volunteer: User | None = ( - self.db.query(User) - .options(joinedload(User.user_data)) - .filter(User.id == req.volunteer_id) - .first() + self.db.query(User).options(joinedload(User.user_data)).filter(User.id == req.volunteer_id).first() ) if not volunteer: raise HTTPException(404, f"Volunteer {req.volunteer_id} not found") @@ -780,30 +774,22 @@ def _calculate_age(birth_date: date) -> Optional[int]: def _has_valid_availability(self, volunteer: User) -> bool: """Check if volunteer has any active availability templates.""" - template_count = ( - self.db.query(AvailabilityTemplate) - .filter_by(user_id=volunteer.id, is_active=True) - .count() - ) - + template_count = self.db.query(AvailabilityTemplate).filter_by(user_id=volunteer.id, is_active=True).count() + return template_count > 0 def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None: """ Projects volunteer's availability templates onto the next 2 weeks and creates TimeBlocks for the match's suggested times. - + Template times are interpreted in the volunteer's local timezone, then converted to UTC for storage. """ now = datetime.now(timezone.utc) - + # Get active availability templates for this volunteer - templates = ( - self.db.query(AvailabilityTemplate) - .filter_by(user_id=volunteer.id, is_active=True) - .all() - ) + templates = self.db.query(AvailabilityTemplate).filter_by(user_id=volunteer.id, is_active=True).all() if not templates: return @@ -812,52 +798,47 @@ def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None volunteer_tz: Optional[ZoneInfo] = None if volunteer.user_data and volunteer.user_data.timezone: volunteer_tz = get_timezone_from_abbreviation(volunteer.user_data.timezone) - + # Default to UTC if no timezone is set (shouldn't happen in production, but handle gracefully) if not volunteer_tz: self.logger.warning( - f"Volunteer {volunteer.id} has no timezone set. " - "Interpreting availability templates as UTC." + f"Volunteer {volunteer.id} has no timezone set. Interpreting availability templates as UTC." ) volunteer_tz = timezone.utc # Project templates onto the next week projection_weeks = 1 - + for day_offset in range(projection_weeks * 7): # Calculate target date in UTC target_date_utc = now + timedelta(days=day_offset) - + # Convert UTC date to volunteer's local date to get the correct weekday # Templates are defined in the volunteer's local timezone, so we must # compare against the local weekday, not the UTC weekday target_date_local = target_date_utc.astimezone(volunteer_tz).date() target_day_of_week = target_date_local.weekday() # 0=Mon, 6=Sun (in volunteer's timezone) - + # Find templates that match this day of week for template in templates: if template.day_of_week == target_day_of_week: # Create datetime in volunteer's local timezone - current_time_local = datetime.combine( - target_date_local, - template.start_time - ).replace(tzinfo=volunteer_tz) - - end_time_local = datetime.combine( - target_date_local, - template.end_time - ).replace(tzinfo=volunteer_tz) - + current_time_local = datetime.combine(target_date_local, template.start_time).replace( + tzinfo=volunteer_tz + ) + + end_time_local = datetime.combine(target_date_local, template.end_time).replace(tzinfo=volunteer_tz) + # Convert to UTC for storage current_time_utc = current_time_local.astimezone(timezone.utc) end_time_utc = end_time_local.astimezone(timezone.utc) - + while current_time_utc < end_time_utc: # Ensure we don't add blocks in the past if current_time_utc >= now: new_block = TimeBlock(start_time=current_time_utc) match.suggested_time_blocks.append(new_block) - + current_time_utc += timedelta(minutes=30) def _reassign_volunteer(self, match: Match, volunteer: User) -> None: diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index fa6abcea..c50c0ee9 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -7,7 +7,8 @@ from sqlalchemy.orm import Session, joinedload from app.interfaces.user_service import IUserService -from app.models import FormStatus, Role, User, UserData, VolunteerData, Treatment, Experience +from app.models import Experience, FormStatus, Role, Treatment, User, UserData +from app.schemas.availability import AvailabilityTemplateSlot from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -168,28 +169,26 @@ async def get_user_by_id(self, user_id: str) -> UserResponse: ) if not user: raise HTTPException(status_code=404, detail="User not found") - + # Convert templates to AvailabilityTemplateSlot for UserResponse - from app.schemas.availability import AvailabilityTemplateSlot - availability_templates = [] for template in user.availability_templates: if template.is_active: - availability_templates.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - + availability_templates.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, start_time=template.start_time, end_time=template.end_time + ) + ) + # Create a temporary user object with availability for validation user_dict = { **{c.name: getattr(user, c.name) for c in user.__table__.columns}, - 'availability': availability_templates, - 'role': user.role, - 'user_data': user.user_data, - 'volunteer_data': user.volunteer_data, + "availability": availability_templates, + "role": user.role, + "user_data": user.user_data, + "volunteer_data": user.volunteer_data, } - + return UserResponse.model_validate(user_dict) except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") @@ -227,30 +226,30 @@ async def get_users(self) -> List[UserResponse]: .filter(User.role_id.in_([1, 2])) .all() ) - + # Convert templates to AvailabilityTemplateSlot for each user - from app.schemas.availability import AvailabilityTemplateSlot - user_responses = [] for user in users: availability_templates = [] for template in user.availability_templates: if template.is_active: - availability_templates.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - + availability_templates.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time, + ) + ) + user_dict = { **{c.name: getattr(user, c.name) for c in user.__table__.columns}, - 'availability': availability_templates, - 'role': user.role, - 'user_data': user.user_data, - 'volunteer_data': user.volunteer_data, + "availability": availability_templates, + "role": user.role, + "user_data": user.user_data, + "volunteer_data": user.volunteer_data, } user_responses.append(UserResponse.model_validate(user_dict)) - + return user_responses except Exception as e: self.logger.error(f"Error getting users: {str(e)}") @@ -268,30 +267,30 @@ async def get_admins(self) -> List[UserResponse]: .filter(User.role_id == 3) .all() ) - + # Convert templates to AvailabilityTemplateSlot for each admin (though admins typically don't have availability) - from app.schemas.availability import AvailabilityTemplateSlot - user_responses = [] for user in users: availability_templates = [] for template in user.availability_templates: if template.is_active: - availability_templates.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - + availability_templates.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, + start_time=template.start_time, + end_time=template.end_time, + ) + ) + user_dict = { **{c.name: getattr(user, c.name) for c in user.__table__.columns}, - 'availability': availability_templates, - 'role': user.role, - 'user_data': user.user_data, - 'volunteer_data': user.volunteer_data, + "availability": availability_templates, + "role": user.role, + "user_data": user.user_data, + "volunteer_data": user.volunteer_data, } user_responses.append(UserResponse.model_validate(user_dict)) - + return user_responses except Exception as e: self.logger.error(f"Error retrieving admin users: {str(e)}") @@ -332,27 +331,25 @@ async def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) .filter(User.id == UUID(user_id)) .first() ) - + # Convert templates to AvailabilityTemplateSlot for UserResponse - from app.schemas.availability import AvailabilityTemplateSlot - availability_templates = [] for template in updated_user.availability_templates: if template.is_active: - availability_templates.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - + availability_templates.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, start_time=template.start_time, end_time=template.end_time + ) + ) + user_dict = { **{c.name: getattr(updated_user, c.name) for c in updated_user.__table__.columns}, - 'availability': availability_templates, - 'role': updated_user.role, - 'user_data': updated_user.user_data, - 'volunteer_data': updated_user.volunteer_data, + "availability": availability_templates, + "role": updated_user.role, + "user_data": updated_user.user_data, + "volunteer_data": updated_user.volunteer_data, } - + return UserResponse.model_validate(user_dict) except ValueError: @@ -386,63 +383,76 @@ async def update_user_data_by_id(self, user_id: str, user_data_update: UserDataU # Update simple fields (personal info, demographics, cancer experience) simple_fields = [ # Personal Information - 'first_name', 'last_name', 'date_of_birth', 'phone', 'city', 'province', 'postal_code', + "first_name", + "last_name", + "date_of_birth", + "phone", + "city", + "province", + "postal_code", # Demographics - 'gender_identity', 'marital_status', 'has_kids', 'timezone', + "gender_identity", + "marital_status", + "has_kids", + "timezone", # Cancer Experience - 'diagnosis', 'date_of_diagnosis', 'additional_info', + "diagnosis", + "date_of_diagnosis", + "additional_info", # Loved One Demographics - 'loved_one_gender_identity', 'loved_one_age', + "loved_one_gender_identity", + "loved_one_age", # Loved One Cancer Experience - 'loved_one_diagnosis', 'loved_one_date_of_diagnosis' + "loved_one_diagnosis", + "loved_one_date_of_diagnosis", ] for field in simple_fields: if field in update_data: setattr(user_data, field, update_data[field]) # Handle pronouns (array field) - if 'pronouns' in update_data: - user_data.pronouns = update_data['pronouns'] + if "pronouns" in update_data: + user_data.pronouns = update_data["pronouns"] # Handle ethnic_group (array field) - if 'ethnic_group' in update_data: - user_data.ethnic_group = update_data['ethnic_group'] + if "ethnic_group" in update_data: + user_data.ethnic_group = update_data["ethnic_group"] # Handle treatments (many-to-many) - if 'treatments' in update_data: + if "treatments" in update_data: user_data.treatments.clear() - if update_data['treatments']: - for treatment_name in update_data['treatments']: + if update_data["treatments"]: + for treatment_name in update_data["treatments"]: if treatment_name: treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() if treatment: user_data.treatments.append(treatment) # Handle experiences (many-to-many) - if 'experiences' in update_data: + if "experiences" in update_data: user_data.experiences.clear() - if update_data['experiences']: - for experience_name in update_data['experiences']: + if update_data["experiences"]: + for experience_name in update_data["experiences"]: if experience_name: experience = self.db.query(Experience).filter(Experience.name == experience_name).first() if experience: user_data.experiences.append(experience) # Handle loved one treatments (many-to-many) - if 'loved_one_treatments' in update_data: + if "loved_one_treatments" in update_data: user_data.loved_one_treatments.clear() - if update_data['loved_one_treatments']: - for treatment_name in update_data['loved_one_treatments']: + if update_data["loved_one_treatments"]: + for treatment_name in update_data["loved_one_treatments"]: if treatment_name: treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() if treatment: user_data.loved_one_treatments.append(treatment) # Handle loved one experiences (many-to-many) - if 'loved_one_experiences' in update_data: + if "loved_one_experiences" in update_data: user_data.loved_one_experiences.clear() - if update_data['loved_one_experiences']: - for experience_name in update_data['loved_one_experiences']: + if update_data["loved_one_experiences"]: + for experience_name in update_data["loved_one_experiences"]: if experience_name: experience = self.db.query(Experience).filter(Experience.name == experience_name).first() if experience: @@ -466,28 +476,26 @@ async def update_user_data_by_id(self, user_id: str, user_data_update: UserDataU .filter(User.id == UUID(user_id)) .first() ) - + # Convert templates to AvailabilityTemplateSlot for UserResponse (same as get_user_by_id) - from app.schemas.availability import AvailabilityTemplateSlot - availability_templates = [] for template in updated_user.availability_templates: if template.is_active: - availability_templates.append(AvailabilityTemplateSlot( - day_of_week=template.day_of_week, - start_time=template.start_time, - end_time=template.end_time - )) - + availability_templates.append( + AvailabilityTemplateSlot( + day_of_week=template.day_of_week, start_time=template.start_time, end_time=template.end_time + ) + ) + # Create a temporary user object with availability for validation user_dict = { **{c.name: getattr(updated_user, c.name) for c in updated_user.__table__.columns}, - 'availability': availability_templates, - 'role': updated_user.role, - 'user_data': updated_user.user_data, - 'volunteer_data': updated_user.volunteer_data, + "availability": availability_templates, + "role": updated_user.role, + "user_data": updated_user.user_data, + "volunteer_data": updated_user.volunteer_data, } - + return UserResponse.model_validate(user_dict) except ValueError: diff --git a/backend/app/utilities/timezone_utils.py b/backend/app/utilities/timezone_utils.py index f80c3275..54a93fe2 100644 --- a/backend/app/utilities/timezone_utils.py +++ b/backend/app/utilities/timezone_utils.py @@ -2,17 +2,17 @@ Utility functions for handling Canadian timezone abbreviations. Maps abbreviations (NST, AST, EST, CST, MST, PST) to IANA timezone identifiers. """ -from zoneinfo import ZoneInfo -from typing import Optional +from typing import Optional +from zoneinfo import ZoneInfo # Map Canadian timezone abbreviations to IANA timezone identifiers CANADIAN_TIMEZONE_MAP = { "NST": ZoneInfo("America/St_Johns"), # Newfoundland Standard Time - "AST": ZoneInfo("America/Halifax"), # Atlantic Standard Time - "EST": ZoneInfo("America/Toronto"), # Eastern Standard Time - "CST": ZoneInfo("America/Winnipeg"), # Central Standard Time - "MST": ZoneInfo("America/Edmonton"), # Mountain Standard Time + "AST": ZoneInfo("America/Halifax"), # Atlantic Standard Time + "EST": ZoneInfo("America/Toronto"), # Eastern Standard Time + "CST": ZoneInfo("America/Winnipeg"), # Central Standard Time + "MST": ZoneInfo("America/Edmonton"), # Mountain Standard Time "PST": ZoneInfo("America/Vancouver"), # Pacific Standard Time } @@ -20,13 +20,13 @@ def get_timezone_from_abbreviation(abbreviation: Optional[str]) -> Optional[ZoneInfo]: """ Convert a Canadian timezone abbreviation to a ZoneInfo object. - + Args: abbreviation: One of NST, AST, EST, CST, MST, PST, or None - + Returns: ZoneInfo object for the timezone, or None if abbreviation is None/invalid - + Examples: >>> tz = get_timezone_from_abbreviation("EST") >>> tz @@ -34,6 +34,5 @@ def get_timezone_from_abbreviation(abbreviation: Optional[str]) -> Optional[Zone """ if not abbreviation: return None - - return CANADIAN_TIMEZONE_MAP.get(abbreviation.upper()) + return CANADIAN_TIMEZONE_MAP.get(abbreviation.upper()) diff --git a/backend/migrations/versions/2141551638c9_add_availability_templates_table.py b/backend/migrations/versions/2141551638c9_add_availability_templates_table.py index fa5fa064..8802d2c2 100644 --- a/backend/migrations/versions/2141551638c9_add_availability_templates_table.py +++ b/backend/migrations/versions/2141551638c9_add_availability_templates_table.py @@ -5,36 +5,41 @@ Create Date: 2025-11-20 14:20:36.802308 """ + from typing import Sequence, Union import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision: str = '2141551638c9' -down_revision: Union[str, None] = '23dae9594e1d' +revision: str = "2141551638c9" +down_revision: Union[str, None] = "23dae9594e1d" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('availability_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('day_of_week', sa.Integer(), nullable=False), - sa.Column('start_time', sa.Time(), nullable=False), - sa.Column('end_time', sa.Time(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + "availability_templates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("day_of_week", sa.Integer(), nullable=False), + sa.Column("start_time", sa.Time(), nullable=False), + sa.Column("end_time", sa.Time(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('availability_templates') + op.drop_table("availability_templates") # ### end Alembic commands ### diff --git a/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py b/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py index b4668cf3..d81a5e6a 100644 --- a/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py +++ b/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py @@ -5,30 +5,31 @@ Create Date: 2025-11-20 14:40:37.626596 """ + from typing import Sequence, Union import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision: str = 'dda4b46776e9' -down_revision: Union[str, None] = '2141551638c9' +revision: str = "dda4b46776e9" +down_revision: Union[str, None] = "2141551638c9" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Drop the available_times association table (replaced by availability_templates) - op.drop_table('available_times') + op.drop_table("available_times") def downgrade() -> None: # Recreate available_times table (for rollback purposes) op.create_table( - 'available_times', - sa.Column('time_block_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.ForeignKeyConstraint(['time_block_id'], ['time_blocks.id']), - sa.ForeignKeyConstraint(['user_id'], ['users.id']), - sa.PrimaryKeyConstraint('time_block_id', 'user_id') + "available_times", + sa.Column("time_block_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["time_block_id"], ["time_blocks.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("time_block_id", "user_id"), ) diff --git a/backend/tests/unit/test_availability_service.py b/backend/tests/unit/test_availability_service.py index 8151a1fc..a6ccf212 100644 --- a/backend/tests/unit/test_availability_service.py +++ b/backend/tests/unit/test_availability_service.py @@ -2,22 +2,23 @@ Tests for AvailabilityService with the new template-based system. Tests timezone handling, template creation, and projection. """ + import os -import pytest -from datetime import date, datetime, timedelta, time as dt_time, timezone +from datetime import time as dt_time from uuid import uuid4 -from zoneinfo import ZoneInfo + +import pytest from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from app.models import AvailabilityTemplate, Role, User, UserData -from app.schemas.user import UserRole from app.schemas.availability import ( AvailabilityTemplateSlot, CreateAvailabilityRequest, DeleteAvailabilityRequest, GetAvailabilityRequest, ) +from app.schemas.user import UserRole from app.services.implementations.availability_service import AvailabilityService # Test DB Configuration @@ -38,11 +39,7 @@ def db_session(): try: # Clean up any existing data first - session.execute( - text( - "TRUNCATE TABLE availability_templates, user_data, users RESTART IDENTITY CASCADE" - ) - ) + session.execute(text("TRUNCATE TABLE availability_templates, user_data, users RESTART IDENTITY CASCADE")) session.commit() # Ensure roles exist @@ -74,7 +71,7 @@ def volunteer_user(db_session): last_name="Volunteer", ) db_session.add(user) - + user_data = UserData( id=uuid4(), user_id=user.id, @@ -98,7 +95,7 @@ def pst_volunteer(db_session): last_name="Volunteer", ) db_session.add(user) - + user_data = UserData( id=uuid4(), user_id=user.id, @@ -114,7 +111,7 @@ def pst_volunteer(db_session): async def test_create_availability_adds_templates(db_session, volunteer_user): """Test that creating availability adds templates correctly""" availability_service = AvailabilityService(db_session) - + # Create templates: Monday 10:00 AM to 11:30 AM templates = [ AvailabilityTemplateSlot( @@ -123,17 +120,17 @@ async def test_create_availability_adds_templates(db_session, volunteer_user): end_time=dt_time(11, 30), ) ] - + create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + assert result.user_id == volunteer_user.id assert result.added == 3 # 10:00, 10:30, 11:00 (3 templates) - + # Verify templates were created templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 3 @@ -150,7 +147,7 @@ async def test_create_availability_adds_templates(db_session, volunteer_user): async def test_create_availability_replaces_existing(db_session, volunteer_user): """Test that creating availability replaces all existing templates""" availability_service = AvailabilityService(db_session) - + # Create initial templates templates1 = [ AvailabilityTemplateSlot( @@ -162,7 +159,7 @@ async def test_create_availability_replaces_existing(db_session, volunteer_user) await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates1) ) - + # Create new templates (should replace old ones) templates2 = [ AvailabilityTemplateSlot( @@ -174,9 +171,9 @@ async def test_create_availability_replaces_existing(db_session, volunteer_user) result = await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates2) ) - + assert result.added == 2 # 14:00, 14:30 - + # Verify old templates are gone, new ones exist templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 2 @@ -190,7 +187,7 @@ async def test_create_availability_replaces_existing(db_session, volunteer_user) async def test_create_availability_multiple_ranges(db_session, volunteer_user): """Test creating availability with multiple time ranges""" availability_service = AvailabilityService(db_session) - + templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday @@ -203,16 +200,16 @@ async def test_create_availability_multiple_ranges(db_session, volunteer_user): end_time=dt_time(15, 0), ), ] - + create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + assert result.added == 4 # 9:00, 9:30, 14:00, 14:30 - + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 4 @@ -221,7 +218,7 @@ async def test_create_availability_multiple_ranges(db_session, volunteer_user): async def test_get_availability_returns_templates(db_session, volunteer_user): """Test that getting availability returns templates""" availability_service = AvailabilityService(db_session) - + # Create templates templates = [ AvailabilityTemplateSlot( @@ -233,11 +230,11 @@ async def test_get_availability_returns_templates(db_session, volunteer_user): await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + # Get availability get_request = GetAvailabilityRequest(user_id=volunteer_user.id) result = await availability_service.get_availability(get_request) - + assert result.user_id == volunteer_user.id # Service creates individual 30-minute templates, so 10:00-11:00 creates 2 templates (10:00-10:30, 10:30-11:00) assert len(result.templates) == 2 @@ -251,7 +248,7 @@ async def test_get_availability_returns_templates(db_session, volunteer_user): async def test_get_availability_only_active(db_session, volunteer_user): """Test that getting availability only returns active templates""" availability_service = AvailabilityService(db_session) - + # Create active template templates = [ AvailabilityTemplateSlot( @@ -263,7 +260,7 @@ async def test_get_availability_only_active(db_session, volunteer_user): await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + # Manually create inactive template inactive_template = AvailabilityTemplate( user_id=volunteer_user.id, @@ -274,11 +271,11 @@ async def test_get_availability_only_active(db_session, volunteer_user): ) db_session.add(inactive_template) db_session.commit() - + # Get availability get_request = GetAvailabilityRequest(user_id=volunteer_user.id) result = await availability_service.get_availability(get_request) - + # Service creates 2 templates for 10:00-11:00 (30-minute blocks) assert len(result.templates) == 2 assert all(t.day_of_week == 0 for t in result.templates) # Only active templates @@ -288,7 +285,7 @@ async def test_get_availability_only_active(db_session, volunteer_user): async def test_delete_availability_removes_templates(db_session, volunteer_user): """Test that deleting availability removes templates correctly""" availability_service = AvailabilityService(db_session) - + # Create templates templates = [ AvailabilityTemplateSlot( @@ -300,7 +297,7 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + # Delete part of availability delete_templates = [ AvailabilityTemplateSlot( @@ -313,16 +310,14 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + assert result.deleted == 2 assert len(result.templates) == 2 # Remaining: 11:00, 11:30 - + # Verify remaining templates - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 2 times = {t.start_time for t in remaining} assert dt_time(11, 0) in times @@ -333,7 +328,7 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) async def test_delete_availability_ignores_non_existent(db_session, volunteer_user): """Test that deleting availability ignores non-existent templates""" availability_service = AvailabilityService(db_session) - + # Create some templates templates = [ AvailabilityTemplateSlot( @@ -345,7 +340,7 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + # Try to delete non-existent templates delete_templates = [ AvailabilityTemplateSlot( @@ -358,16 +353,14 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + assert result.deleted == 0 assert len(result.templates) == 2 # Original templates still there - + # Verify templates still exist - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 2 @@ -375,7 +368,7 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us async def test_delete_all_availability(db_session, volunteer_user): """Test deleting all availability""" availability_service = AvailabilityService(db_session) - + # Create availability templates = [ AvailabilityTemplateSlot( @@ -387,7 +380,7 @@ async def test_delete_all_availability(db_session, volunteer_user): await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + # Delete all availability delete_templates = [ AvailabilityTemplateSlot( @@ -400,16 +393,14 @@ async def test_delete_all_availability(db_session, volunteer_user): user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + assert result.deleted == 4 assert len(result.templates) == 0 - + # Verify no templates remain - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 0 @@ -417,7 +408,7 @@ async def test_delete_all_availability(db_session, volunteer_user): async def test_create_availability_invalid_day_of_week(db_session, volunteer_user): """Test that invalid day_of_week raises error""" availability_service = AvailabilityService(db_session) - + templates = [ AvailabilityTemplateSlot( day_of_week=7, # Invalid (should be 0-6) @@ -429,7 +420,7 @@ async def test_create_availability_invalid_day_of_week(db_session, volunteer_use user_id=volunteer_user.id, templates=templates, ) - + with pytest.raises(Exception): # Should raise HTTPException await availability_service.create_availability(create_request) @@ -438,7 +429,7 @@ async def test_create_availability_invalid_day_of_week(db_session, volunteer_use async def test_create_availability_invalid_time_range(db_session, volunteer_user): """Test that invalid time range (end <= start) raises error""" availability_service = AvailabilityService(db_session) - + templates = [ AvailabilityTemplateSlot( day_of_week=0, @@ -450,7 +441,7 @@ async def test_create_availability_invalid_time_range(db_session, volunteer_user user_id=volunteer_user.id, templates=templates, ) - + with pytest.raises(Exception): # Should raise HTTPException await availability_service.create_availability(create_request) @@ -459,7 +450,7 @@ async def test_create_availability_invalid_time_range(db_session, volunteer_user async def test_create_availability_user_not_found(db_session): """Test that creating availability raises error for non-existent user""" availability_service = AvailabilityService(db_session) - + fake_user_id = uuid4() templates = [ AvailabilityTemplateSlot( @@ -472,7 +463,7 @@ async def test_create_availability_user_not_found(db_session): user_id=fake_user_id, templates=templates, ) - + with pytest.raises(Exception): # Should raise HTTPException await availability_service.create_availability(create_request) @@ -481,35 +472,33 @@ async def test_create_availability_user_not_found(db_session): async def test_pst_user_can_submit_8am_to_8pm(db_session, pst_volunteer): """Test that PST users can submit 8am-8pm PST templates""" availability_service = AvailabilityService(db_session) - + # PST user submits 8am-8pm PST templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday - start_time=dt_time(8, 0), # 8am PST - end_time=dt_time(20, 0), # 8pm PST + start_time=dt_time(8, 0), # 8am PST + end_time=dt_time(20, 0), # 8pm PST ) ] - + create_request = CreateAvailabilityRequest( user_id=pst_volunteer.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + # Should create 24 templates (8am-8pm in 30-min increments) assert result.added == 24 - + # Verify templates stored as local time (not converted to UTC) - templates = db_session.query(AvailabilityTemplate).filter_by( - user_id=pst_volunteer.id, is_active=True - ).all() + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=pst_volunteer.id, is_active=True).all() assert len(templates) == 24 - + # Check first and last templates times = sorted([t.start_time for t in templates]) - assert times[0] == dt_time(8, 0) # 8am PST + assert times[0] == dt_time(8, 0) # 8am PST assert times[-1] == dt_time(19, 30) # 7:30pm PST (last 30-min block before 8pm) @@ -517,33 +506,30 @@ async def test_pst_user_can_submit_8am_to_8pm(db_session, pst_volunteer): async def test_est_user_can_submit_8am_to_8pm(db_session, volunteer_user): """Test that EST users can submit 8am-8pm EST templates""" availability_service = AvailabilityService(db_session) - + # EST user submits 8am-8pm EST templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday - start_time=dt_time(8, 0), # 8am EST - end_time=dt_time(20, 0), # 8pm EST + start_time=dt_time(8, 0), # 8am EST + end_time=dt_time(20, 0), # 8pm EST ) ] - + create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + # Should create 24 templates assert result.added == 24 - + # Verify templates stored as local time - templates = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(templates) == 24 - + times = sorted([t.start_time for t in templates]) - assert times[0] == dt_time(8, 0) # 8am EST + assert times[0] == dt_time(8, 0) # 8am EST assert times[-1] == dt_time(19, 30) # 7:30pm EST - diff --git a/backend/tests/unit/test_match_service.py b/backend/tests/unit/test_match_service.py index 9c5533b7..d7478af4 100644 --- a/backend/tests/unit/test_match_service.py +++ b/backend/tests/unit/test_match_service.py @@ -20,7 +20,8 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker -from app.models import AvailabilityTemplate, Match, MatchStatus, Role, TimeBlock, User, UserData +from app.models import Match, MatchStatus, Role, TimeBlock, User, UserData +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest from app.schemas.match import ( MatchCreateRequest, MatchRequestNewVolunteersResponse, @@ -28,11 +29,8 @@ ) from app.schemas.time_block import TimeRange from app.schemas.user import UserRole -from app.services.implementations.match_service import MatchService -from app.services.implementations.availability_service import AvailabilityService -from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest from app.services.implementations.availability_service import AvailabilityService -from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest +from app.services.implementations.match_service import MatchService # Check for Postgres test database (same pattern as test_user.py) POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") @@ -189,20 +187,20 @@ async def volunteer_with_availability(db_session, volunteer_user): db_session.add(user_data) db_session.commit() db_session.refresh(volunteer_user) - + # Create availability templates: Monday 10:00-12:00 EST availability_service = AvailabilityService(db_session) templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday start_time=datetime(2000, 1, 1, 10, 0).time(), # 10:00 - end_time=datetime(2000, 1, 1, 12, 0).time(), # 12:00 + end_time=datetime(2000, 1, 1, 12, 0).time(), # 12:00 ) ] await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + db_session.refresh(volunteer_user) return volunteer_user @@ -218,20 +216,20 @@ async def volunteer_with_mixed_availability(db_session, another_volunteer): db_session.add(user_data) db_session.commit() db_session.refresh(another_volunteer) - + # Create availability templates: Tuesday 14:00-15:00 EST availability_service = AvailabilityService(db_session) templates = [ AvailabilityTemplateSlot( day_of_week=1, # Tuesday start_time=datetime(2000, 1, 1, 14, 0).time(), # 14:00 - end_time=datetime(2000, 1, 1, 15, 0).time(), # 15:00 + end_time=datetime(2000, 1, 1, 15, 0).time(), # 15:00 ) ] await availability_service.create_availability( CreateAvailabilityRequest(user_id=another_volunteer.id, templates=templates) ) - + db_session.refresh(another_volunteer) return another_volunteer @@ -248,7 +246,7 @@ async def volunteer_with_alt_availability(db_session): ) db_session.add(volunteer) db_session.flush() - + # Create user_data with EST timezone user_data = UserData( user_id=volunteer.id, @@ -257,19 +255,17 @@ async def volunteer_with_alt_availability(db_session): db_session.add(user_data) db_session.commit() db_session.refresh(volunteer) - + # Create availability templates: Wednesday 9:00-10:00 EST availability_service = AvailabilityService(db_session) templates = [ AvailabilityTemplateSlot( day_of_week=2, # Wednesday - start_time=datetime(2000, 1, 1, 9, 0).time(), # 9:00 - end_time=datetime(2000, 1, 1, 10, 0).time(), # 10:00 + start_time=datetime(2000, 1, 1, 9, 0).time(), # 9:00 + end_time=datetime(2000, 1, 1, 10, 0).time(), # 10:00 ) ] - await availability_service.create_availability( - CreateAvailabilityRequest(user_id=volunteer.id, templates=templates) - ) + await availability_service.create_availability(CreateAvailabilityRequest(user_id=volunteer.id, templates=templates)) db_session.commit() db_session.refresh(volunteer) diff --git a/backend/tests/unit/test_match_service_timezone.py b/backend/tests/unit/test_match_service_timezone.py index aadbe12e..de146807 100644 --- a/backend/tests/unit/test_match_service_timezone.py +++ b/backend/tests/unit/test_match_service_timezone.py @@ -1,21 +1,23 @@ """ Tests for MatchService timezone handling when projecting availability templates. """ + import os +from datetime import datetime, timezone +from datetime import time as dt_time +from uuid import uuid4 + import pytest import pytest_asyncio -from datetime import datetime, timedelta, time as dt_time, timezone -from uuid import uuid4 -from zoneinfo import ZoneInfo from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker -from app.models import AvailabilityTemplate, Match, MatchStatus, Role, TimeBlock, User, UserData +from app.models import Match, MatchStatus, Role, User, UserData +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest from app.schemas.match import MatchCreateRequest from app.schemas.user import UserRole from app.services.implementations.availability_service import AvailabilityService from app.services.implementations.match_service import MatchService -from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest # Test DB Configuration POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") @@ -53,7 +55,7 @@ def db_session(): for role in seed_roles: if role.id not in existing: session.add(role) - + # Ensure match statuses exist existing_statuses = {s.name for s in session.query(MatchStatus).all()} statuses = [ @@ -64,7 +66,7 @@ def db_session(): for status in statuses: if status.name not in existing_statuses: session.add(status) - + session.commit() yield session @@ -101,7 +103,7 @@ async def est_volunteer(db_session): last_name="Volunteer", ) db_session.add(user) - + user_data = UserData( id=uuid4(), user_id=user.id, @@ -110,20 +112,18 @@ async def est_volunteer(db_session): db_session.add(user_data) db_session.commit() db_session.refresh(user) - + # Create availability templates: Monday 2pm-4pm EST availability_service = AvailabilityService(db_session) templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday start_time=dt_time(14, 0), # 2pm EST - end_time=dt_time(16, 0), # 4pm EST + end_time=dt_time(16, 0), # 4pm EST ) ] - await availability_service.create_availability( - CreateAvailabilityRequest(user_id=user.id, templates=templates) - ) - + await availability_service.create_availability(CreateAvailabilityRequest(user_id=user.id, templates=templates)) + db_session.refresh(user) return user @@ -140,7 +140,7 @@ async def pst_volunteer(db_session): last_name="Volunteer", ) db_session.add(user) - + user_data = UserData( id=uuid4(), user_id=user.id, @@ -149,20 +149,18 @@ async def pst_volunteer(db_session): db_session.add(user_data) db_session.commit() db_session.refresh(user) - + # Create availability templates: Monday 8am-10am PST availability_service = AvailabilityService(db_session) templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday - start_time=dt_time(8, 0), # 8am PST - end_time=dt_time(10, 0), # 10am PST + start_time=dt_time(8, 0), # 8am PST + end_time=dt_time(10, 0), # 10am PST ) ] - await availability_service.create_availability( - CreateAvailabilityRequest(user_id=user.id, templates=templates) - ) - + await availability_service.create_availability(CreateAvailabilityRequest(user_id=user.id, templates=templates)) + db_session.refresh(user) return user @@ -171,37 +169,37 @@ async def pst_volunteer(db_session): async def test_est_template_projects_to_utc_correctly(db_session, participant_user, est_volunteer): """Test that EST templates project to correct UTC times""" match_service = MatchService(db_session) - + # Create match with EST volunteer create_request = MatchCreateRequest( participant_id=participant_user.id, volunteer_ids=[est_volunteer.id], match_status="pending", # This will trigger template projection ) - + result = await match_service.create_matches(create_request) assert len(result.matches) == 1 - + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() assert match is not None - + # Get suggested time blocks suggested_blocks = match.suggested_time_blocks assert len(suggested_blocks) > 0 - + # EST is UTC-5 in winter, UTC-4 in summer (EDT) # 2pm EST = 7pm UTC (winter) or 6pm UTC (summer) # 4pm EST = 9pm UTC (winter) or 8pm UTC (summer) # We should have blocks at the correct UTC times utc_times = sorted([block.start_time for block in suggested_blocks]) - + # Check that times are in UTC assert all(tz.tzinfo == timezone.utc for tz in utc_times) - + # Check that times are in the future now = datetime.now(timezone.utc) assert all(tz >= now for tz in utc_times) - + # Verify times correspond to Monday 2pm-4pm EST # Find a Monday in the next week for block in suggested_blocks: @@ -218,36 +216,36 @@ async def test_est_template_projects_to_utc_correctly(db_session, participant_us async def test_pst_template_projects_to_utc_correctly(db_session, participant_user, pst_volunteer): """Test that PST templates project to correct UTC times""" match_service = MatchService(db_session) - + # Create match with PST volunteer create_request = MatchCreateRequest( participant_id=participant_user.id, volunteer_ids=[pst_volunteer.id], match_status="pending", ) - + result = await match_service.create_matches(create_request) assert len(result.matches) == 1 - + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() assert match is not None - + # Get suggested time blocks suggested_blocks = match.suggested_time_blocks assert len(suggested_blocks) > 0 - + # PST is UTC-8 in winter, UTC-7 in summer (PDT) # 8am PST = 4pm UTC (winter) or 3pm UTC (summer) # 10am PST = 6pm UTC (winter) or 5pm UTC (summer) utc_times = sorted([block.start_time for block in suggested_blocks]) - + # Check that times are in UTC assert all(tz.tzinfo == timezone.utc for tz in utc_times) - + # Check that times are in the future now = datetime.now(timezone.utc) assert all(tz >= now for tz in utc_times) - + # Verify times correspond to Monday 8am-10am PST for block in suggested_blocks: if block.start_time.weekday() == 0: # Monday @@ -263,30 +261,30 @@ async def test_pst_template_projects_to_utc_correctly(db_session, participant_us async def test_volunteer_accept_match_projects_templates(db_session, participant_user, est_volunteer): """Test that volunteer accepting match projects templates correctly""" match_service = MatchService(db_session) - + # Create match with awaiting_volunteer_acceptance status create_request = MatchCreateRequest( participant_id=participant_user.id, volunteer_ids=[est_volunteer.id], match_status="awaiting_volunteer_acceptance", ) - + result = await match_service.create_matches(create_request) assert len(result.matches) == 1 - + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() # Initially no suggested times (awaiting acceptance) assert len(match.suggested_time_blocks) == 0 - + # Volunteer accepts match - detail = await match_service.volunteer_accept_match(match.id, est_volunteer.id) - + await match_service.volunteer_accept_match(match.id, est_volunteer.id) + # Refresh match db_session.refresh(match) - + # Should now have suggested times projected from templates assert len(match.suggested_time_blocks) > 0 - + # Verify times are in UTC for block in match.suggested_time_blocks: assert block.start_time.tzinfo == timezone.utc @@ -305,7 +303,7 @@ async def test_no_timezone_defaults_to_utc(db_session, participant_user): last_name="Timezone", ) db_session.add(volunteer) - + user_data = UserData( id=uuid4(), user_id=volunteer.id, @@ -314,7 +312,7 @@ async def test_no_timezone_defaults_to_utc(db_session, participant_user): db_session.add(user_data) db_session.commit() db_session.refresh(volunteer) - + # Create availability templates availability_service = AvailabilityService(db_session) templates = [ @@ -324,10 +322,8 @@ async def test_no_timezone_defaults_to_utc(db_session, participant_user): end_time=dt_time(16, 0), ) ] - await availability_service.create_availability( - CreateAvailabilityRequest(user_id=volunteer.id, templates=templates) - ) - + await availability_service.create_availability(CreateAvailabilityRequest(user_id=volunteer.id, templates=templates)) + # Create match match_service = MatchService(db_session) create_request = MatchCreateRequest( @@ -335,17 +331,17 @@ async def test_no_timezone_defaults_to_utc(db_session, participant_user): volunteer_ids=[volunteer.id], match_status="pending", ) - + result = await match_service.create_matches(create_request) assert len(result.matches) == 1 - + match = db_session.query(Match).filter_by(id=result.matches[0].id).first() - + # Should still work (defaults to UTC) # Templates interpreted as UTC, so 2pm UTC = 2pm UTC suggested_blocks = match.suggested_time_blocks assert len(suggested_blocks) > 0 - + # Verify times are in UTC for block in suggested_blocks: assert block.start_time.tzinfo == timezone.utc @@ -353,4 +349,3 @@ async def test_no_timezone_defaults_to_utc(db_session, participant_user): # Without timezone, templates are interpreted as UTC, so 2pm template = 2pm UTC # But DST might affect this, so allow for both 14 and 15 (depending on when test runs) assert block.start_time.hour in [14, 15], f"Expected 14 or 15 UTC, got {block.start_time.hour}" - diff --git a/backend/tests/unit/test_user_data_update.py b/backend/tests/unit/test_user_data_update.py index 1b33a447..713b065f 100644 --- a/backend/tests/unit/test_user_data_update.py +++ b/backend/tests/unit/test_user_data_update.py @@ -1,21 +1,23 @@ import os -import pytest -from datetime import date, datetime, timedelta, time as dt_time, timezone +from datetime import date +from datetime import time as dt_time from uuid import uuid4 + +import pytest from sqlalchemy import create_engine, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker -from app.models import AvailabilityTemplate, Role, User, UserData, Treatment, Experience, TimeBlock -from app.schemas.user import UserRole -from app.schemas.user_data import UserDataUpdateRequest +from app.models import AvailabilityTemplate, Experience, Role, Treatment, User, UserData from app.schemas.availability import ( AvailabilityTemplateSlot, CreateAvailabilityRequest, DeleteAvailabilityRequest, ) -from app.services.implementations.user_service import UserService +from app.schemas.user import UserRole +from app.schemas.user_data import UserDataUpdateRequest from app.services.implementations.availability_service import AvailabilityService +from app.services.implementations.user_service import UserService # Test DB Configuration - Always require Postgres for full parity POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") @@ -31,8 +33,6 @@ @pytest.fixture(scope="function") def db_session(): """Provide a clean database session for each test""" - from sqlalchemy import text - from sqlalchemy.exc import IntegrityError session = TestingSessionLocal() @@ -219,11 +219,11 @@ async def test_update_treatments_clears_old_and_adds_new(db_session, test_user_w assert result.user_data is not None result_treatment_names = {t.name for t in result.user_data.treatments} - + # Verify old treatments are removed assert "Chemotherapy" not in result_treatment_names assert "Radiation" not in result_treatment_names - + # Verify new treatments are added assert "Immunotherapy" in result_treatment_names assert "Oral Chemotherapy" in result_treatment_names @@ -251,11 +251,11 @@ async def test_update_experiences_clears_old_and_adds_new(db_session, test_user_ assert result.user_data is not None result_experience_names = {e.name for e in result.user_data.experiences} - + # Verify old experiences are removed assert "Fatigue" not in result_experience_names assert "Anxiety / Depression" not in result_experience_names - + # Verify new experiences are added assert "Brain Fog" in result_experience_names assert "Feeling Overwhelmed" in result_experience_names @@ -284,12 +284,12 @@ async def test_update_treatments_with_empty_list_clears_all(db_session, test_use async def test_update_loved_one_treatments(db_session, test_user_with_data): """Test updating loved one treatments""" user, user_data = test_user_with_data - + # Add initial loved one treatments treatment1 = db_session.query(Treatment).filter(Treatment.name == "Chemotherapy").first() user_data.loved_one_treatments.append(treatment1) db_session.commit() - + user_service = UserService(db_session) # Update with new loved one treatments @@ -301,10 +301,10 @@ async def test_update_loved_one_treatments(db_session, test_user_with_data): assert result.user_data is not None result_treatment_names = {t.name for t in result.user_data.loved_one_treatments} - + # Verify old treatment is removed assert "Chemotherapy" not in result_treatment_names - + # Verify new treatments are added assert "Radiation" in result_treatment_names assert "Immunotherapy" in result_treatment_names @@ -315,12 +315,12 @@ async def test_update_loved_one_treatments(db_session, test_user_with_data): async def test_update_loved_one_experiences(db_session, test_user_with_data): """Test updating loved one experiences""" user, user_data = test_user_with_data - + # Add initial loved one experiences experience1 = db_session.query(Experience).filter(Experience.name == "Fatigue").first() user_data.loved_one_experiences.append(experience1) db_session.commit() - + user_service = UserService(db_session) # Update with new loved one experiences @@ -332,10 +332,10 @@ async def test_update_loved_one_experiences(db_session, test_user_with_data): assert result.user_data is not None result_experience_names = {e.name for e in result.user_data.loved_one_experiences} - + # Verify old experience is removed assert "Fatigue" not in result_experience_names - + # Verify new experiences are added assert "Anxiety / Depression" in result_experience_names assert "Brain Fog" in result_experience_names @@ -407,7 +407,7 @@ async def test_update_with_invalid_treatment_name_ignores_it(db_session, test_us assert result.user_data is not None treatment_names = {t.name for t in result.user_data.treatments} - + # Only valid treatments should be added assert "Chemotherapy" in treatment_names assert "Radiation" in treatment_names @@ -425,7 +425,7 @@ async def test_update_user_not_found_raises_error(db_session): with pytest.raises(Exception) as exc_info: await user_service.update_user_data_by_id(fake_user_id, update_request) - + assert "not found" in str(exc_info.value).lower() or exc_info.value.status_code == 404 @@ -494,7 +494,7 @@ def volunteer_user(db_session): async def test_create_availability_adds_templates(db_session, volunteer_user): """Test that creating availability adds templates correctly""" availability_service = AvailabilityService(db_session) - + # Create templates: Monday 10:00 AM to 11:30 AM templates = [ AvailabilityTemplateSlot( @@ -503,17 +503,17 @@ async def test_create_availability_adds_templates(db_session, volunteer_user): end_time=dt_time(11, 30), ) ] - + create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + assert result.user_id == volunteer_user.id assert result.added == 3 # 10:00, 10:30, 11:00 (3 templates) - + # Verify templates were created templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 3 @@ -529,7 +529,7 @@ async def test_create_availability_adds_templates(db_session, volunteer_user): async def test_create_availability_multiple_ranges(db_session, volunteer_user): """Test creating availability with multiple time ranges""" availability_service = AvailabilityService(db_session) - + templates = [ AvailabilityTemplateSlot( day_of_week=0, # Monday @@ -542,17 +542,17 @@ async def test_create_availability_multiple_ranges(db_session, volunteer_user): end_time=dt_time(15, 0), ), ] - + create_request = CreateAvailabilityRequest( user_id=volunteer_user.id, templates=templates, ) - + result = await availability_service.create_availability(create_request) - + # Should add 4 templates total (2 from each range) assert result.added == 4 - + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 4 @@ -561,7 +561,7 @@ async def test_create_availability_multiple_ranges(db_session, volunteer_user): async def test_delete_availability_removes_templates(db_session, volunteer_user): """Test that deleting availability removes templates correctly""" availability_service = AvailabilityService(db_session) - + # First, create some availability templates = [ AvailabilityTemplateSlot( @@ -573,10 +573,10 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 4 # 10:00, 10:30, 11:00, 11:30 - + # Now delete a portion of it (10:00 to 11:00, should remove 2 templates) delete_templates = [ AvailabilityTemplateSlot( @@ -585,21 +585,19 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) end_time=dt_time(11, 0), ) ] - + delete_request = DeleteAvailabilityRequest( user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + assert result.user_id == volunteer_user.id assert result.deleted == 2 # Removed 10:00 and 10:30 - + # Verify remaining templates - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 2 # Should have 11:00 and 11:30 left times = {t.start_time for t in remaining} assert dt_time(11, 0) in times @@ -610,7 +608,7 @@ async def test_delete_availability_removes_templates(db_session, volunteer_user) async def test_delete_availability_ignores_non_existent(db_session, volunteer_user): """Test that deleting availability ignores non-existent templates""" availability_service = AvailabilityService(db_session) - + # Create some availability templates = [ AvailabilityTemplateSlot( @@ -622,10 +620,10 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 2 - + # Try to delete templates that don't exist (Tuesday 14:00 to 15:00) delete_templates = [ AvailabilityTemplateSlot( @@ -634,21 +632,19 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us end_time=dt_time(15, 0), ) ] - + delete_request = DeleteAvailabilityRequest( user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + # Should delete 0 templates since none exist assert result.deleted == 0 - + # Verify original templates are still there - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 2 @@ -656,7 +652,7 @@ async def test_delete_availability_ignores_non_existent(db_session, volunteer_us async def test_delete_all_availability(db_session, volunteer_user): """Test deleting all availability""" availability_service = AvailabilityService(db_session) - + # Create availability templates = [ AvailabilityTemplateSlot( @@ -668,10 +664,10 @@ async def test_delete_all_availability(db_session, volunteer_user): await availability_service.create_availability( CreateAvailabilityRequest(user_id=volunteer_user.id, templates=templates) ) - + templates = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id).all() assert len(templates) == 4 - + # Delete all availability delete_templates = [ AvailabilityTemplateSlot( @@ -684,15 +680,13 @@ async def test_delete_all_availability(db_session, volunteer_user): user_id=volunteer_user.id, templates=delete_templates, ) - + result = await availability_service.delete_availability(delete_request) - + assert result.deleted == 4 - + # Verify all templates are removed - remaining = db_session.query(AvailabilityTemplate).filter_by( - user_id=volunteer_user.id, is_active=True - ).all() + remaining = db_session.query(AvailabilityTemplate).filter_by(user_id=volunteer_user.id, is_active=True).all() assert len(remaining) == 0 @@ -700,7 +694,7 @@ async def test_delete_all_availability(db_session, volunteer_user): async def test_delete_availability_user_not_found(db_session): """Test that deleting availability raises error for non-existent user""" availability_service = AvailabilityService(db_session) - + fake_user_id = uuid4() delete_templates = [ AvailabilityTemplateSlot( @@ -709,15 +703,15 @@ async def test_delete_availability_user_not_found(db_session): end_time=dt_time(11, 0), ) ] - + delete_request = DeleteAvailabilityRequest( user_id=fake_user_id, templates=delete_templates, ) - + with pytest.raises(Exception) as exc_info: await availability_service.delete_availability(delete_request) - + # The service currently raises 500 for user not found (could be improved to 404) assert exc_info.value.status_code == 500 @@ -726,7 +720,7 @@ async def test_delete_availability_user_not_found(db_session): async def test_create_availability_user_not_found(db_session): """Test that creating availability raises error for non-existent user""" availability_service = AvailabilityService(db_session) - + fake_user_id = uuid4() templates = [ AvailabilityTemplateSlot( @@ -735,15 +729,14 @@ async def test_create_availability_user_not_found(db_session): end_time=dt_time(11, 0), ) ] - + create_request = CreateAvailabilityRequest( user_id=fake_user_id, templates=templates, ) - + with pytest.raises(Exception) as exc_info: await availability_service.create_availability(create_request) - + # The service currently raises 500 for user not found (could be improved to 404) assert exc_info.value.status_code == 500 - diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 71b098d1..9fa80e30 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -462,22 +462,26 @@ export const updateUserData = async ( lovedOneDateOfDiagnosis?: string; lovedOneTreatments?: string[]; lovedOneExperiences?: string[]; - } + }, ): Promise => { // Convert camelCase to snake_case for backend const backendData: Record = {}; - + if (userDataUpdate.firstName !== undefined) backendData.first_name = userDataUpdate.firstName; if (userDataUpdate.lastName !== undefined) backendData.last_name = userDataUpdate.lastName; - if (userDataUpdate.dateOfBirth !== undefined) backendData.date_of_birth = userDataUpdate.dateOfBirth; + if (userDataUpdate.dateOfBirth !== undefined) + backendData.date_of_birth = userDataUpdate.dateOfBirth; if (userDataUpdate.phone !== undefined) backendData.phone = userDataUpdate.phone; if (userDataUpdate.city !== undefined) backendData.city = userDataUpdate.city; if (userDataUpdate.province !== undefined) backendData.province = userDataUpdate.province; if (userDataUpdate.postalCode !== undefined) backendData.postal_code = userDataUpdate.postalCode; - if (userDataUpdate.genderIdentity !== undefined) backendData.gender_identity = userDataUpdate.genderIdentity; + if (userDataUpdate.genderIdentity !== undefined) + backendData.gender_identity = userDataUpdate.genderIdentity; if (userDataUpdate.pronouns !== undefined) backendData.pronouns = userDataUpdate.pronouns; - if (userDataUpdate.ethnicGroup !== undefined) backendData.ethnic_group = userDataUpdate.ethnicGroup; - if (userDataUpdate.maritalStatus !== undefined) backendData.marital_status = userDataUpdate.maritalStatus; + if (userDataUpdate.ethnicGroup !== undefined) + backendData.ethnic_group = userDataUpdate.ethnicGroup; + if (userDataUpdate.maritalStatus !== undefined) + backendData.marital_status = userDataUpdate.maritalStatus; if (userDataUpdate.hasKids !== undefined) backendData.has_kids = userDataUpdate.hasKids; if (userDataUpdate.timezone !== undefined) backendData.timezone = userDataUpdate.timezone; if (userDataUpdate.diagnosis !== undefined) backendData.diagnosis = userDataUpdate.diagnosis; @@ -486,19 +490,29 @@ export const updateUserData = async ( backendData.date_of_diagnosis = userDataUpdate.dateOfDiagnosis; } if (userDataUpdate.treatments !== undefined) backendData.treatments = userDataUpdate.treatments; - if (userDataUpdate.experiences !== undefined) backendData.experiences = userDataUpdate.experiences; - if (userDataUpdate.additionalInfo !== undefined) backendData.additional_info = userDataUpdate.additionalInfo; - if (userDataUpdate.lovedOneGenderIdentity !== undefined) backendData.loved_one_gender_identity = userDataUpdate.lovedOneGenderIdentity; - if (userDataUpdate.lovedOneAge !== undefined) backendData.loved_one_age = userDataUpdate.lovedOneAge; - if (userDataUpdate.lovedOneDiagnosis !== undefined) backendData.loved_one_diagnosis = userDataUpdate.lovedOneDiagnosis; + if (userDataUpdate.experiences !== undefined) + backendData.experiences = userDataUpdate.experiences; + if (userDataUpdate.additionalInfo !== undefined) + backendData.additional_info = userDataUpdate.additionalInfo; + if (userDataUpdate.lovedOneGenderIdentity !== undefined) + backendData.loved_one_gender_identity = userDataUpdate.lovedOneGenderIdentity; + if (userDataUpdate.lovedOneAge !== undefined) + backendData.loved_one_age = userDataUpdate.lovedOneAge; + if (userDataUpdate.lovedOneDiagnosis !== undefined) + backendData.loved_one_diagnosis = userDataUpdate.lovedOneDiagnosis; if (userDataUpdate.lovedOneDateOfDiagnosis !== undefined) { // Convert null to null (to clear date) or keep the date string backendData.loved_one_date_of_diagnosis = userDataUpdate.lovedOneDateOfDiagnosis; } - if (userDataUpdate.lovedOneTreatments !== undefined) backendData.loved_one_treatments = userDataUpdate.lovedOneTreatments; - if (userDataUpdate.lovedOneExperiences !== undefined) backendData.loved_one_experiences = userDataUpdate.lovedOneExperiences; - - const response = await baseAPIClient.patch(`/users/${userId}/user-data`, backendData); + if (userDataUpdate.lovedOneTreatments !== undefined) + backendData.loved_one_treatments = userDataUpdate.lovedOneTreatments; + if (userDataUpdate.lovedOneExperiences !== undefined) + backendData.loved_one_experiences = userDataUpdate.lovedOneExperiences; + + const response = await baseAPIClient.patch( + `/users/${userId}/user-data`, + backendData, + ); return response.data; }; @@ -508,7 +522,7 @@ export const updateUserData = async ( export interface AvailabilityTemplate { dayOfWeek: number; // 0=Monday, 1=Tuesday, ..., 6=Sunday startTime: string; // Time string in format "HH:MM:SS" or "HH:MM" - endTime: string; // Time string in format "HH:MM:SS" or "HH:MM" + endTime: string; // Time string in format "HH:MM:SS" or "HH:MM" } export interface CreateAvailabilityRequest { @@ -524,10 +538,15 @@ export interface DeleteAvailabilityRequest { /** * Get availability for a user */ -export const getAvailability = async (userId: string): Promise<{ templates: AvailabilityTemplate[] }> => { - const response = await baseAPIClient.get<{ user_id: string; templates: Array<{ day_of_week: number; start_time: string; end_time: string }> }>(`/availability?user_id=${userId}`); +export const getAvailability = async ( + userId: string, +): Promise<{ templates: AvailabilityTemplate[] }> => { + const response = await baseAPIClient.get<{ + user_id: string; + templates: Array<{ day_of_week: number; start_time: string; end_time: string }>; + }>(`/availability?user_id=${userId}`); return { - templates: response.data.templates.map(t => ({ + templates: response.data.templates.map((t) => ({ dayOfWeek: t.day_of_week, startTime: t.start_time, endTime: t.end_time, @@ -538,38 +557,49 @@ export const getAvailability = async (userId: string): Promise<{ templates: Avai /** * Create availability for a user */ -export const createAvailability = async (request: CreateAvailabilityRequest): Promise<{ userId: string; added: number }> => { +export const createAvailability = async ( + request: CreateAvailabilityRequest, +): Promise<{ userId: string; added: number }> => { // Convert camelCase to snake_case for backend const backendData = { user_id: request.userId, - templates: request.templates.map(template => ({ + templates: request.templates.map((template) => ({ day_of_week: template.dayOfWeek, start_time: template.startTime, end_time: template.endTime, })), }; - const response = await baseAPIClient.post<{ user_id: string; added: number }>('/availability', backendData); + const response = await baseAPIClient.post<{ user_id: string; added: number }>( + '/availability', + backendData, + ); return { userId: response.data.user_id, added: response.data.added }; }; /** * Delete availability for a user */ -export const deleteAvailability = async (request: DeleteAvailabilityRequest): Promise<{ userId: string; deleted: number; templates: AvailabilityTemplate[] }> => { +export const deleteAvailability = async ( + request: DeleteAvailabilityRequest, +): Promise<{ userId: string; deleted: number; templates: AvailabilityTemplate[] }> => { // Convert camelCase to snake_case for backend const backendData = { user_id: request.userId, - templates: request.templates.map(template => ({ + templates: request.templates.map((template) => ({ day_of_week: template.dayOfWeek, start_time: template.startTime, end_time: template.endTime, })), }; - const response = await baseAPIClient.delete<{ user_id: string; deleted: number; templates: Array<{ day_of_week: number; start_time: string; end_time: string }> }>('/availability', { data: backendData }); + const response = await baseAPIClient.delete<{ + user_id: string; + deleted: number; + templates: Array<{ day_of_week: number; start_time: string; end_time: string }>; + }>('/availability', { data: backendData }); return { userId: response.data.user_id, deleted: response.data.deleted, - templates: response.data.templates.map(t => ({ + templates: response.data.templates.map((t) => ({ dayOfWeek: t.day_of_week, startTime: t.start_time, endTime: t.end_time, diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 81689c36..302e988b 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -40,9 +40,9 @@ export const AdminHeader: React.FC = () => { {/* Navigation Items */} - { Task List - - + Availability {isEditing ? ( - - - - - - - - - - - - - - + + + + + + ); +} diff --git a/frontend/src/components/admin/userProfile/DeactivateSuccessModal.tsx b/frontend/src/components/admin/userProfile/DeactivateSuccessModal.tsx new file mode 100644 index 00000000..30707d52 --- /dev/null +++ b/frontend/src/components/admin/userProfile/DeactivateSuccessModal.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Box, Button, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { FiCheck } from 'react-icons/fi'; +import { Icon } from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; + +interface DeactivateSuccessModalProps { + isOpen: boolean; + isReactivate: boolean; + onClose: () => void; +} + +export function DeactivateSuccessModal({ + isOpen, + isReactivate, + onClose, +}: DeactivateSuccessModalProps) { + if (!isOpen) return null; + + const title = isReactivate ? 'Account Reactivated' : 'Account Deactivated'; + const description = isReactivate + ? 'This volunteer is now eligible for matches again.' + : 'This volunteer is no longer eligible for matches.'; + + return ( + + + + {/* Checkmark Icon */} + + + + + + + {/* Success Message */} + + + {title} + + + {description} + + + + {/* Okay Button */} + + + + + + + ); +} diff --git a/frontend/src/components/admin/userProfile/DeleteConfirmationModal.tsx b/frontend/src/components/admin/userProfile/DeleteConfirmationModal.tsx new file mode 100644 index 00000000..b0a3862a --- /dev/null +++ b/frontend/src/components/admin/userProfile/DeleteConfirmationModal.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Box, Button, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { FiAlertCircle } from 'react-icons/fi'; +import { Icon } from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; + +interface DeleteConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isProcessing?: boolean; +} + +export function DeleteConfirmationModal({ + isOpen, + onClose, + onConfirm, + isProcessing = false, +}: DeleteConfirmationModalProps) { + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + boxShadow={COLORS.shadow.lg} + > + + {/* Warning Icon */} + + + + + + + {/* Title */} + + Are you sure you want to delete this account? + + + {/* Description */} + + This action cannot be undone. All user data, matches, and related information will be + permanently deleted. + + + {/* Action Buttons */} + + + + + + + + ); +} diff --git a/frontend/src/components/admin/userProfile/DeleteSuccessModal.tsx b/frontend/src/components/admin/userProfile/DeleteSuccessModal.tsx new file mode 100644 index 00000000..a15362a9 --- /dev/null +++ b/frontend/src/components/admin/userProfile/DeleteSuccessModal.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Box, Button, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { FiCheck } from 'react-icons/fi'; +import { Icon } from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; + +interface DeleteSuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function DeleteSuccessModal({ isOpen, onClose }: DeleteSuccessModalProps) { + if (!isOpen) return null; + + return ( + + + + {/* Checkmark Icon */} + + + + + + + {/* Success Message */} + + + Account Deleted + + + The account and all associated data have been permanently deleted. + + + + {/* Okay Button */} + + + + + + + ); +} diff --git a/frontend/src/components/admin/userProfile/ProfileContent.tsx b/frontend/src/components/admin/userProfile/ProfileContent.tsx index 66ea53cc..6ca0cd6b 100644 --- a/frontend/src/components/admin/userProfile/ProfileContent.tsx +++ b/frontend/src/components/admin/userProfile/ProfileContent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Flex, Heading, Text, Button, VStack } from '@chakra-ui/react'; import { UserRole } from '@/types/authTypes'; import { COLORS } from '@/constants/colors'; @@ -8,6 +8,12 @@ import { CancerExperienceSection } from './CancerExperienceSection'; import { LovedOneSection } from './LovedOneSection'; import { AvailabilitySection } from './AvailabilitySection'; import { CancerEditData, LovedOneEditData } from '@/types/userProfileTypes'; +import { useRouter } from 'next/router'; +import { DeactivateConfirmationModal } from './DeactivateConfirmationModal'; +import { DeactivateSuccessModal } from './DeactivateSuccessModal'; +import { DeleteConfirmationModal } from './DeleteConfirmationModal'; +import { DeleteSuccessModal } from './DeleteSuccessModal'; +import { deactivateUser, reactivateUser, deleteUser } from '@/APIClients/authAPIClient'; interface ProfileContentProps { user: UserResponse; @@ -37,6 +43,7 @@ interface ProfileContentProps { onMouseDown: (dayIndex: number, timeIndex: number) => void; onMouseMove: (dayIndex: number, timeIndex: number) => void; onMouseUp: () => void; + setUser: (user: UserResponse | null) => void; } export function ProfileContent({ @@ -67,135 +74,242 @@ export function ProfileContent({ onMouseDown, onMouseMove, onMouseUp, + setUser, }: ProfileContentProps) { - return ( - - - {/* Header Section */} - - - - {user.firstName} {user.lastName} - - - {role} - - - - - - - + const router = useRouter(); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const [showDeleteSuccessModal, setShowDeleteSuccessModal] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [wasReactivated, setWasReactivated] = useState(false); + + const isReactivate = !user.active; + const isVolunteer = role === UserRole.VOLUNTEER; + + const handleDeactivateClick = () => { + setShowConfirmModal(true); + }; + + const handleConfirmDeactivate = async () => { + setIsProcessing(true); + try { + if (isReactivate) { + await reactivateUser(user.id); + // Update user state + setUser({ ...user, active: true }); + setWasReactivated(true); + } else { + await deactivateUser(user.id); + // Update user state + setUser({ ...user, active: false }); + setWasReactivated(false); + } + setShowConfirmModal(false); + setShowSuccessModal(true); + } catch (error) { + console.error('Error deactivating/reactivating user:', error); + // TODO: Show error message to user + } finally { + setIsProcessing(false); + } + }; + + const handleCloseSuccessModal = () => { + setShowSuccessModal(false); + }; + + const handleDeleteClick = () => { + setShowDeleteConfirmModal(true); + }; - {/* Overview - Only for Volunteers */} - {role === UserRole.VOLUNTEER && ( - <> - + const handleConfirmDelete = async () => { + setIsDeleting(true); + try { + await deleteUser(user.id); + setShowDeleteConfirmModal(false); + setShowDeleteSuccessModal(true); + } catch (error) { + console.error('Error deleting user:', error); + setIsDeleting(false); + // TODO: Show error message to user + } + }; + + const handleCloseDeleteSuccessModal = () => { + setShowDeleteSuccessModal(false); + // Redirect to user directory after successful deletion + router.push('/admin/directory'); + }; + + return ( + <> + + + {/* Header Section */} + + - Overview + {user.firstName} {user.lastName} - - {volunteerData?.experience || userData?.additionalInfo || 'No overview provided.'} + + {role} + + {/* Only show deactivate/reactivate button for volunteers */} + {isVolunteer && ( + + )} + + + - - - )} - - {/* Detailed Info */} - - {/* User's Own Cancer Experience */} - - - {/* Loved One Info */} - - - {/* Availability - Only for Volunteers */} + {/* Overview - Only for Volunteers */} {role === UserRole.VOLUNTEER && ( - + <> + + + Overview + + + {volunteerData?.experience || userData?.additionalInfo || 'No overview provided.'} + + + + + )} - + + {/* Detailed Info */} + + {/* User's Own Cancer Experience */} + + + {/* Loved One Info */} + + + {/* Availability - Only for Volunteers */} + {role === UserRole.VOLUNTEER && ( + + )} + + - + + {/* Confirmation Modal */} + setShowConfirmModal(false)} + onConfirm={handleConfirmDeactivate} + isProcessing={isProcessing} + /> + + {/* Success Modal */} + + + {/* Delete Confirmation Modal */} + setShowDeleteConfirmModal(false)} + onConfirm={handleConfirmDelete} + isProcessing={isDeleting} + /> + + {/* Delete Success Modal */} + + ); } diff --git a/frontend/src/components/admin/userProfile/ProfileSummary.tsx b/frontend/src/components/admin/userProfile/ProfileSummary.tsx index 1e5629e6..00380ce1 100644 --- a/frontend/src/components/admin/userProfile/ProfileSummary.tsx +++ b/frontend/src/components/admin/userProfile/ProfileSummary.tsx @@ -1,21 +1,52 @@ import React from 'react'; -import { - Box, - Flex, - Heading, - Text, - Button, - VStack, - HStack, - IconButton, - Input, -} from '@chakra-ui/react'; -import { FiEdit2, FiHeart } from 'react-icons/fi'; +import { Box, Flex, Heading, Text, VStack, HStack, IconButton, Input } from '@chakra-ui/react'; +import { FiEdit2, FiHeart, FiX, FiCheck } from 'react-icons/fi'; import { COLORS } from '@/constants/colors'; import { formatArray, capitalizeWords } from '@/utils/userProfileUtils'; import { formatDateLong } from '@/utils/dateUtils'; import { ProfileEditData } from '@/types/userProfileTypes'; import { UserData } from '@/types/userTypes'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; +import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; + +// Options from intake forms +const GENDER_IDENTITY_OPTIONS = [ + 'Male', + 'Female', + 'Non-binary', + 'Transgender', + 'Prefer not to answer', + 'Self-describe', +]; + +const PRONOUNS_OPTIONS = [ + 'He/Him', + 'She/Her', + 'They/Them', + 'Ze/Zir', + 'Prefer not to answer', + 'Self-describe', +]; + +const TIMEZONE_OPTIONS = ['NST', 'AST', 'EST', 'CST', 'MST', 'PST']; + +const MARITAL_STATUS_OPTIONS = ['Single', 'Married/Common Law', 'Divorced', 'Widowed']; + +const HAS_KIDS_OPTIONS = ['Yes', 'No', 'Prefer not to answer']; + +const ETHNIC_OPTIONS = [ + 'Black (including African and Caribbean descent)', + 'Middle Eastern, Western or Central Asian', + 'East Asian', + 'South Asian', + 'Southeast Asian', + 'Indigenous person from Canada', + 'Latin American', + 'White', + 'Mixed Ethnicity (Individuals who identify with more than one racial/ethnic or cultural group)', + 'Prefer not to answer', + 'Another background/Prefer to self-describe (please specify):', +]; interface ProfileSummaryProps { userData: UserData | null | undefined; @@ -66,25 +97,28 @@ export function ProfileSummary({ ) : ( - - + + )} @@ -102,6 +136,9 @@ export function ProfileSummary({ onChange={(e) => onEditDataChange({ ...editData, firstName: e.target.value })} placeholder="First Name" fontSize="sm" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" /> onEditDataChange({ ...editData, lastName: e.target.value })} placeholder="Last Name" fontSize="sm" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" /> ) : ( @@ -138,6 +178,9 @@ export function ProfileSummary({ value={editData.dateOfBirth || ''} onChange={(e) => onEditDataChange({ ...editData, dateOfBirth: e.target.value })} fontSize="sm" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" /> ) : ( @@ -157,6 +200,9 @@ export function ProfileSummary({ onChange={(e) => onEditDataChange({ ...editData, phone: e.target.value })} placeholder="Phone Number" fontSize="sm" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" /> ) : ( @@ -170,12 +216,13 @@ export function ProfileSummary({ Gender {isEditing ? ( - onEditDataChange({ ...editData, genderIdentity: e.target.value })} - placeholder="Gender" - fontSize="sm" + + onEditDataChange({ ...editData, genderIdentity: value }) + } + placeholder="Select gender" /> ) : ( @@ -189,20 +236,11 @@ export function ProfileSummary({ Pronouns {isEditing ? ( - - onEditDataChange({ - ...editData, - pronouns: e.target.value - .split(',') - .map((p) => p.trim()) - .filter(Boolean), - }) - } - placeholder="Pronouns (comma-separated)" - fontSize="sm" + onEditDataChange({ ...editData, pronouns: values })} + placeholder="Select pronouns" /> ) : ( @@ -216,12 +254,11 @@ export function ProfileSummary({ Time Zone {isEditing ? ( - onEditDataChange({ ...editData, timezone: e.target.value })} - placeholder="Time Zone" - fontSize="sm" + onEditDataChange({ ...editData, timezone: value })} + placeholder="Select time zone" /> ) : ( @@ -235,20 +272,11 @@ export function ProfileSummary({ Ethnic or Cultural Group {isEditing ? ( - - onEditDataChange({ - ...editData, - ethnicGroup: e.target.value - .split(',') - .map((g) => g.trim()) - .filter(Boolean), - }) - } - placeholder="Ethnic or Cultural Group (comma-separated)" - fontSize="sm" + onEditDataChange({ ...editData, ethnicGroup: values })} + placeholder="Select ethnic or cultural group" /> ) : ( @@ -271,12 +299,11 @@ export function ProfileSummary({ Marital Status {isEditing ? ( - onEditDataChange({ ...editData, maritalStatus: e.target.value })} - placeholder="Marital Status" - fontSize="sm" + onEditDataChange({ ...editData, maritalStatus: value })} + placeholder="Select marital status" /> ) : ( @@ -290,12 +317,11 @@ export function ProfileSummary({ Parental Status {isEditing ? ( - onEditDataChange({ ...editData, hasKids: e.target.value })} - placeholder="Parental Status" - fontSize="sm" + onEditDataChange({ ...editData, hasKids: value })} + placeholder="Select parental status" /> ) : ( @@ -316,16 +342,16 @@ export function ProfileSummary({ {isEditing ? ( - - onEditDataChange({ ...editData, lovedOneGenderIdentity: e.target.value }) - } - placeholder="Loved One's Gender" - fontSize="sm" - ml={4} - /> + + + onEditDataChange({ ...editData, lovedOneGenderIdentity: value }) + } + placeholder="Select loved one's gender" + /> + ) : ( {userData?.lovedOneGenderIdentity || 'N/A'} @@ -347,6 +373,9 @@ export function ProfileSummary({ placeholder="Loved One's Age" fontSize="sm" ml={4} + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" /> ) : ( diff --git a/frontend/src/components/intake/loved-one-form.tsx b/frontend/src/components/intake/loved-one-form.tsx index b4779e88..7ed7d66a 100644 --- a/frontend/src/components/intake/loved-one-form.tsx +++ b/frontend/src/components/intake/loved-one-form.tsx @@ -221,18 +221,20 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor control={control} rules={{ required: 'Age is required' }} render={({ field }) => ( - + + + )} /> diff --git a/frontend/src/components/intake/volunteer-references-form.tsx b/frontend/src/components/intake/volunteer-references-form.tsx index caaead83..58bc8a3d 100644 --- a/frontend/src/components/intake/volunteer-references-form.tsx +++ b/frontend/src/components/intake/volunteer-references-form.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Heading, Text, Button, VStack, HStack, Input, Textarea } from '@chakra-ui/react'; import { useForm, Controller } from 'react-hook-form'; +import { InputGroup } from '@/components/ui/input-group'; import { COLORS, VALIDATION } from '@/constants/form'; interface VolunteerReferencesFormData { @@ -127,22 +128,24 @@ export function VolunteerReferencesForm({ control={control} rules={{ required: 'Full name is required' }} render={({ field }) => ( - + + + )} /> {errors.reference1?.fullName && ( @@ -173,23 +176,25 @@ export function VolunteerReferencesForm({ }, }} render={({ field }) => ( - + + + )} /> {errors.reference1?.email && ( @@ -220,22 +225,24 @@ export function VolunteerReferencesForm({ }, }} render={({ field }) => ( - + + + )} /> {errors.reference1?.phoneNumber && ( @@ -278,22 +285,24 @@ export function VolunteerReferencesForm({ control={control} rules={{ required: 'Full name is required' }} render={({ field }) => ( - + + + )} /> {errors.reference2?.fullName && ( @@ -324,23 +333,25 @@ export function VolunteerReferencesForm({ }, }} render={({ field }) => ( - + + + )} /> {errors.reference2?.email && ( @@ -371,22 +382,24 @@ export function VolunteerReferencesForm({ }, }} render={({ field }) => ( - + + + )} /> {errors.reference2?.phoneNumber && ( diff --git a/frontend/src/pages/admin/users/[id].tsx b/frontend/src/pages/admin/users/[id].tsx index b35d7ccf..ceeec4fb 100644 --- a/frontend/src/pages/admin/users/[id].tsx +++ b/frontend/src/pages/admin/users/[id].tsx @@ -166,6 +166,7 @@ export default function AdminUserProfile() { onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} + setUser={setUser} /> ) : activeTab === 'forms' ? ( diff --git a/frontend/src/types/userTypes.ts b/frontend/src/types/userTypes.ts index 8a8f5f6d..835f9647 100644 --- a/frontend/src/types/userTypes.ts +++ b/frontend/src/types/userTypes.ts @@ -72,6 +72,7 @@ export interface UserResponse { roleId: number; authId: string; approved: boolean; + active: boolean; formStatus: string; role: { id: number; From cf668cff7bf64219ea1e9b5297fa96143d9e9164 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sat, 22 Nov 2025 14:53:39 -0500 Subject: [PATCH 8/9] fix for ci --- .../app/services/implementations/user_service.py | 5 +++-- frontend/src/utils/dateUtils.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index dbf2323f..74cc373b 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -3,6 +3,7 @@ from uuid import UUID import firebase_admin.auth +import firebase_admin.exceptions from fastapi import HTTPException from sqlalchemy.orm import Session, joinedload from sqlalchemy.sql import func @@ -87,7 +88,7 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: if firebase_user: try: firebase_admin.auth.delete_user(firebase_user.uid) - except firebase_admin.auth.AuthError as firebase_error: + except firebase_admin.exceptions.FirebaseError as firebase_error: self.logger.error( "Failed to delete Firebase user after database insertion failed" f"Firebase UID: {firebase_user.uid}. " @@ -187,7 +188,7 @@ async def delete_user_by_id(self, user_id: str): try: firebase_admin.auth.delete_user(firebase_auth_id) self.logger.info(f"Successfully deleted Firebase user {firebase_auth_id}") - except firebase_admin.auth.AuthError as firebase_error: + except firebase_admin.exceptions.FirebaseError as firebase_error: # Log error but don't fail - DB deletion already succeeded self.logger.error( f"Failed to delete Firebase user {firebase_auth_id} after database deletion: {str(firebase_error)}" diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index cbd7120f..e38e355f 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -36,11 +36,21 @@ export function formatDateShort(dateString: string): string { /** * Format a date to show full date (e.g., "February 26, 2024") - * @param dateString - ISO 8601 datetime string + * @param dateString - ISO 8601 date string (YYYY-MM-DD) or datetime string * @returns Formatted date string */ export function formatDateLong(dateString: string): string { - const date = new Date(dateString); + // For date-only strings (YYYY-MM-DD), parse as local date to avoid timezone issues + // For datetime strings, use as-is + let date: Date; + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + // Date-only format: parse as local date + const [year, month, day] = dateString.split('-').map(Number); + date = new Date(year, month - 1, day); + } else { + // Datetime format: parse normally + date = new Date(dateString); + } return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } From 936184bd038f640146e53fffd4f2ffcf8e2ca3d0 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sat, 22 Nov 2025 15:04:22 -0500 Subject: [PATCH 9/9] oops forgot to stage --- backend/tests/unit/test_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 8285018f..287fc27c 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -253,7 +253,7 @@ async def test_delete_user_by_email(db_session): @pytest.mark.asyncio -async def test_delete_user_by_id(db_session): +async def test_delete_user_by_id(mock_firebase_auth, db_session): """Test deleting a user by ID""" try: # Arrange