diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0117b90c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# .pre-commit-config.yaml +# Root-level config to handle both frontend and backend linting/formatting + +default_language_version: + python: python3.12 + +default_stages: [pre-commit, pre-push] + +repos: + # Backend: Ruff linting and formatting + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.6.7 + hooks: + - id: ruff + args: [--fix, --line-length=120] + files: ^backend/ + - id: ruff-format + args: [--line-length=120] + files: ^backend/ + + # Frontend: Prettier formatting + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$ + exclude: ^frontend/(node_modules|\.next|out|build)/ + types_or: [file] + additional_dependencies: + - prettier@3.6.2 diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml deleted file mode 100644 index db3ca70b..00000000 --- a/backend/.pre-commit-config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# .pre-commit-config.yaml - -# Other usable hooks https://pre-commit.com/hooks.html - -default_language_version: - python: python3.12 - -default_stages: [pre-commit, pre-push] - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-added-large-files - - id: check-merge-conflict - - id: check-json - - id: check-toml - - id: debug-statements - - id: detect-private-key - - id: pretty-format-json - args: [--autofix] - - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.6.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format - - - repo: https://github.com/gitleaks/gitleaks - rev: v8.18.0 - hooks: - - id: gitleaks diff --git a/backend/app/models/AvailabilityTemplate.py b/backend/app/models/AvailabilityTemplate.py new file mode 100644 index 00000000..08737cbf --- /dev/null +++ b/backend/app/models/AvailabilityTemplate.py @@ -0,0 +1,34 @@ +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 .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/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 eec68444..47579ba0 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 @@ -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( @@ -110,6 +127,7 @@ async def delete_user( async def deactivate_user( user_id: str, user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), ): try: await user_service.soft_delete_user_by_id(user_id) @@ -118,3 +136,19 @@ async def deactivate_user( raise http_ex except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# reactivate user +@router.post("/{user_id}/reactivate") +async def reactivate_user( + user_id: str, + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + await user_service.reactivate_user_by_id(user_id) + return {"message": "User reactivated successfully"} + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/schemas/availability.py b/backend/app/schemas/availability.py index 23568d21..f6d77ae5 100644 --- a/backend/app/schemas/availability.py +++ b/backend/app/schemas/availability.py @@ -1,14 +1,21 @@ +from datetime import time from typing import List from uuid import UUID from pydantic import BaseModel -from app.schemas.time_block import TimeBlockEntity, TimeRange + +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 +29,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 dc084c09..4aa72190 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 .availability import AvailabilityTemplateSlot +from .user_data import UserDataResponse +from .volunteer_data import VolunteerDataResponse + # TODO: # confirm complexity rules for fields (such as password) @@ -135,8 +139,12 @@ class UserResponse(BaseModel): role_id: int auth_id: str approved: bool + active: bool role: "RoleResponse" form_status: FormStatus + user_data: Optional[UserDataResponse] = None + volunteer_data: Optional[VolunteerDataResponse] = None + availability: List[AvailabilityTemplateSlot] = [] 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..da961e3e --- /dev/null +++ b/backend/app/schemas/user_data.py @@ -0,0 +1,112 @@ +from datetime import date +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +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) + + +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..b2df082c 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -3,9 +3,16 @@ 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 @@ -38,11 +45,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 +70,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 +93,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 +106,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 +129,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 +163,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 +188,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 +214,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 +239,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 +264,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 +288,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 +301,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 +315,54 @@ 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 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 # Create user user = User( @@ -325,6 +382,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 +391,32 @@ 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/availability_service.py b/backend/app/services/implementations/availability_service.py index 1a99d27c..0ce1a242 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 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,14 +24,26 @@ 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() - validated_data = AvailabilityEntity.model_validate( - {"user_id": user_id, "available_times": user.availability} - ) + # 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() + + # 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}) return validated_data except Exception as e: @@ -38,40 +52,67 @@ 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) + # 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)", + ) + + # Validate time range + if template_slot.end_time <= template_slot.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, + ) + 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 +120,58 @@ 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 + # 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) + .delete() + ) + deleted += deleted_count - if block: - self.db.delete(block) - deleted += 1 + self.db.flush() - curr_start += timedelta(hours=0.5) + # Get remaining templates for response + templates = self.db.query(AvailabilityTemplate).filter_by(user_id=user_id, is_active=True).all() - self.db.flush() + 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 +181,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 7ff0bdb1..9571ca84 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -2,11 +2,12 @@ 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.schemas.match import ( MatchCreateRequest, @@ -23,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", @@ -63,7 +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.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") @@ -113,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.availability)) - .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") @@ -150,7 +149,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 +474,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,43 +773,73 @@ 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() - 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 + return template_count > 0 def _attach_initial_suggested_times(self, match: Match, volunteer: User) -> None: - if not volunteer.availability: - return + """ + 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) - 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 + # Get active availability templates for this volunteer + templates = self.db.query(AvailabilityTemplate).filter_by(user_id=volunteer.id, is_active=True).all() - new_block = TimeBlock(start_time=block.start_time) - match.suggested_time_blocks.append(new_block) + if not templates: + return + + # 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) + + # 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." + ) + 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) + + # 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: match.volunteer_id = volunteer.id diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 995a073a..74cc373b 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -3,11 +3,26 @@ from uuid import UUID import firebase_admin.auth +import firebase_admin.exceptions from fastapi import HTTPException -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload +from sqlalchemy.sql import func from app.interfaces.user_service import IUserService -from app.models import FormStatus, Role, User +from app.models import ( + AvailabilityTemplate, + Experience, + FormStatus, + FormSubmission, + Match, + RankingPreference, + Role, + Task, + Treatment, + User, + UserData, +) +from app.schemas.availability import AvailabilityTemplateSlot from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -16,6 +31,7 @@ UserRole, UserUpdateRequest, ) +from app.schemas.user_data import UserDataUpdateRequest from app.utilities.constants import LOGGER_NAME @@ -72,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}. " @@ -102,14 +118,82 @@ async def delete_user_by_email(self, email: str): raise HTTPException(status_code=500, detail=str(e)) async def delete_user_by_id(self, user_id: str): + """ + Delete a user and all related data. + This includes: + - UserData and its many-to-many relationships (treatments, experiences) + - VolunteerData + - AvailabilityTemplate records + - RankingPreference records + - FormSubmission records + - Soft-delete Match records (set deleted_at) + - Handle Task records (set participant_id/assignee_id to NULL or delete) + - Delete Firebase user + - Finally delete the User record + """ + firebase_auth_id = None 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") + # Store Firebase auth_id before deletion + firebase_auth_id = db_user.auth_id + + # 1. Clear many-to-many relationships in UserData (treatments, experiences) + if db_user.user_data: + db_user.user_data.treatments.clear() + db_user.user_data.experiences.clear() + db_user.user_data.loved_one_treatments.clear() + db_user.user_data.loved_one_experiences.clear() + + # 2. Delete UserData + if db_user.user_data: + self.db.delete(db_user.user_data) + + # 3. Delete VolunteerData + if db_user.volunteer_data: + self.db.delete(db_user.volunteer_data) + + # 4. Delete AvailabilityTemplate records + self.db.query(AvailabilityTemplate).filter(AvailabilityTemplate.user_id == db_user.id).delete() + + # 5. Delete RankingPreference records + self.db.query(RankingPreference).filter(RankingPreference.user_id == db_user.id).delete() + + # 6. Delete FormSubmission records + self.db.query(FormSubmission).filter(FormSubmission.user_id == db_user.id).delete() + + # 7. Soft-delete Match records (set deleted_at timestamp) + self.db.query(Match).filter( + (Match.participant_id == db_user.id) | (Match.volunteer_id == db_user.id) + ).update({Match.deleted_at: func.now()}, synchronize_session=False) + + # 8. Handle Task records - set participant_id and assignee_id to NULL + self.db.query(Task).filter(Task.participant_id == db_user.id).update( + {Task.participant_id: None}, synchronize_session=False + ) + self.db.query(Task).filter(Task.assignee_id == db_user.id).update( + {Task.assignee_id: None}, synchronize_session=False + ) + + # 9. Delete the User record self.db.delete(db_user) + + # Commit all database changes self.db.commit() + # 10. Delete Firebase user (after successful DB deletion) + if firebase_auth_id: + try: + firebase_admin.auth.delete_user(firebase_auth_id) + self.logger.info(f"Successfully deleted Firebase user {firebase_auth_id}") + 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)}" + ) + except ValueError: raise HTTPException(status_code=400, detail="Invalid user ID format") except HTTPException: @@ -136,6 +220,23 @@ async def soft_delete_user_by_id(self, user_id: str): self.logger.error(f"Error deactivating user {user_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + async def reactivate_user_by_id(self, user_id: str): + 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") + + db_user.active = True + self.db.commit() + 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 reactivating user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + async def get_user_id_by_auth_id(self, auth_id: str) -> str: """Get user ID for a user by their Firebase auth_id""" user = self.db.query(User).filter(User.auth_id == auth_id).first() @@ -151,10 +252,43 @@ 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_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 + 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: @@ -180,8 +314,42 @@ 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() - return [UserResponse.model_validate(user) for user in users] + users = ( + self.db.query(User) + .options( + joinedload(User.role), + joinedload(User.user_data), + joinedload(User.volunteer_data), + joinedload(User.availability_templates), + ) + .filter(User.role_id.in_([1, 2])) + .all() + ) + + # Convert templates to AvailabilityTemplateSlot for each user + 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)) @@ -189,8 +357,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) + 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)) @@ -220,9 +420,36 @@ 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 + 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") @@ -232,3 +459,152 @@ 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]) + # Sync first_name and last_name to User table for consistency + if field in ("first_name", "last_name"): + setattr(db_user, 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_templates), + ) + .filter(User.id == UUID(user_id)) + .first() + ) + + # Convert templates to AvailabilityTemplateSlot for UserResponse (same as get_user_by_id) + 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") + 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/app/utilities/timezone_utils.py b/backend/app/utilities/timezone_utils.py new file mode 100644 index 00000000..54a93fe2 --- /dev/null +++ b/backend/app/utilities/timezone_utils.py @@ -0,0 +1,38 @@ +""" +Utility functions for handling Canadian timezone abbreviations. +Maps abbreviations (NST, AST, EST, CST, MST, PST) to IANA timezone identifiers. +""" + +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 + "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..8802d2c2 --- /dev/null +++ b/backend/migrations/versions/2141551638c9_add_availability_templates_table.py @@ -0,0 +1,45 @@ +"""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..d81a5e6a --- /dev/null +++ b/backend/migrations/versions/dda4b46776e9_drop_available_times_table.py @@ -0,0 +1,35 @@ +"""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..ec741d59 --- /dev/null +++ b/backend/tests/unit/test_availability_service.py @@ -0,0 +1,539 @@ +""" +Tests for AvailabilityService with the new template-based system. +Tests timezone handling, template creation, and projection. +""" + +import os +from datetime import time as dt_time +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.models import AvailabilityTemplate, Role, User, UserData +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 +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") + +if not POSTGRES_DATABASE_URL: + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "These tests require a Postgres database. Set POSTGRES_TEST_DATABASE_URL to run them.", + allow_module_level=True, + ) + +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..d7478af4 100644 --- a/backend/tests/unit/test_match_service.py +++ b/backend/tests/unit/test_match_service.py @@ -14,12 +14,14 @@ 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 Match, MatchStatus, Role, TimeBlock, User, UserData +from app.schemas.availability import AvailabilityTemplateSlot, CreateAvailabilityRequest from app.schemas.match import ( MatchCreateRequest, MatchRequestNewVolunteersResponse, @@ -27,6 +29,7 @@ ) from app.schemas.time_block import TimeRange from app.schemas.user import UserRole +from app.services.implementations.availability_service import AvailabilityService from app.services.implementations.match_service import MatchService # Check for Postgres test database (same pattern as test_user.py) @@ -56,7 +59,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 +80,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 +96,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 +176,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) +@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) - # 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), + # 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) + ) - for time in times: - block = TimeBlock(start_time=time) - volunteer_user.availability.append(block) - - 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) +@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) - 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: 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) + ) - for time in times: - block = TimeBlock(start_time=time) - another_volunteer.availability.append(block) - - db_session.commit() 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", @@ -234,12 +247,25 @@ 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 +356,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 +469,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 +1674,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..ca821751 --- /dev/null +++ b/backend/tests/unit/test_match_service_timezone.py @@ -0,0 +1,355 @@ +""" +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 sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +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 + +# Test DB Configuration +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") + +if not POSTGRES_DATABASE_URL: + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "These tests require a Postgres database. Set POSTGRES_TEST_DATABASE_URL to run them.", + allow_module_level=True, + ) + +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 + 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_ranking_service.py b/backend/tests/unit/test_ranking_service.py index c6976955..b4583e30 100644 --- a/backend/tests/unit/test_ranking_service.py +++ b/backend/tests/unit/test_ranking_service.py @@ -12,10 +12,13 @@ # Postgres-only configuration (migrations assumed to be applied) POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") + if not POSTGRES_DATABASE_URL: - raise RuntimeError( - "POSTGRES_DATABASE_URL is not set. Please export a Postgres URL, e.g. " - "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "These tests require a Postgres database. Set POSTGRES_TEST_DATABASE_URL to run them.", + allow_module_level=True, ) engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 6ecea9c2..287fc27c 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -20,10 +20,13 @@ # 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_DATABASE_URL is not set. Please export a Postgres URL, e.g. " - "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "These tests require a Postgres database. Set POSTGRES_TEST_DATABASE_URL to run them.", + allow_module_level=True, ) engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -87,7 +90,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" ) ) @@ -250,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 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..a00f040a --- /dev/null +++ b/backend/tests/unit/test_user_data_update.py @@ -0,0 +1,788 @@ +import os +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, Experience, Role, Treatment, User, UserData +from app.schemas.availability import ( + AvailabilityTemplateSlot, + CreateAvailabilityRequest, + DeleteAvailabilityRequest, +) +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") + +if not POSTGRES_DATABASE_URL: + # Skip all tests in this file if Postgres isn't available + pytest.skip( + "POSTGRES_TEST_DATABASE_URL not set. " + "These tests require a Postgres database. Set POSTGRES_TEST_DATABASE_URL to run them.", + allow_module_level=True, + ) + +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 user_loved_one_experiences, user_loved_one_treatments, " + "user_experiences, user_treatments, availability_templates, 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) + + # Verify that User table was also updated (name sync) + db_session.refresh(user) + assert user.first_name == "Jane" + # Verify response top-level fields reflect the updated name + assert result.first_name == "Jane" + assert result.last_name == "Doe" # last_name wasn't updated, so should remain "Doe" + + +@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_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) + + +@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) + + # 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 + + +@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) + + # First, create some availability + 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) + ) + + 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( + day_of_week=0, + start_time=dt_time(10, 0), + 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() + 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(db_session, volunteer_user): + """Test that deleting availability ignores non-existent templates""" + availability_service = AvailabilityService(db_session) + + # Create some availability + 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) + ) + + 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( + 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) + + # 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() + 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) + ) + + 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, + 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() + assert len(remaining) == 0 + + +@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() + 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, + 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 + + +@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) 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 + + +@pytest.mark.asyncio +async def test_update_name_syncs_to_user_table(db_session, test_user_with_data): + """Test that updating first_name and last_name in UserData also updates User table""" + user, user_data = test_user_with_data + user_service = UserService(db_session) + + # Verify initial state + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user_data.first_name == "John" + assert user_data.last_name == "Doe" + + # Update names via UserData + update_request = UserDataUpdateRequest( + first_name="Jane", + last_name="Smith", + ) + + result = await user_service.update_user_data_by_id(str(user.id), update_request) + + # Verify UserData was updated + db_session.refresh(user_data) + assert user_data.first_name == "Jane" + assert user_data.last_name == "Smith" + + # Verify User table was also updated (for consistency) + db_session.refresh(user) + assert user.first_name == "Jane" + assert user.last_name == "Smith" + + # Verify response also reflects the updated names + assert result.first_name == "Jane" + assert result.last_name == "Smith" 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 efa0223d..20a7ea1a 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[]; @@ -439,3 +431,202 @@ 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 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; + templates: AvailabilityTemplate[]; +} + +export interface DeleteAvailabilityRequest { + userId: string; + templates: AvailabilityTemplate[]; +} + +/** + * 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}`); + return { + templates: response.data.templates.map((t) => ({ + dayOfWeek: t.day_of_week, + startTime: t.start_time, + endTime: t.end_time, + })), + }; +}; + +/** + * 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, + 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, + ); + 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[] }> => { + // Convert camelCase to snake_case for backend + const backendData = { + user_id: request.userId, + 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 }); + return { + userId: response.data.user_id, + deleted: response.data.deleted, + templates: response.data.templates.map((t) => ({ + dayOfWeek: t.day_of_week, + startTime: t.start_time, + endTime: t.end_time, + })), + }; +}; + +/** + * Deactivate a user (soft delete) + */ +export const deactivateUser = async (userId: string): Promise<{ message: string }> => { + const response = await baseAPIClient.post<{ message: string }>(`/users/${userId}/deactivate`); + return response.data; +}; + +/** + * Reactivate a user + */ +export const reactivateUser = async (userId: string): Promise<{ message: string }> => { + const response = await baseAPIClient.post<{ message: string }>(`/users/${userId}/reactivate`); + return response.data; +}; + +/** + * Delete a user (permanent deletion) + */ +export const deleteUser = async (userId: string): Promise<{ message: string }> => { + const response = await baseAPIClient.delete<{ message: string }>(`/users/${userId}`); + return response.data; +}; diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index beb1fd74..302e988b 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..7226b6a5 --- /dev/null +++ b/frontend/src/components/admin/userProfile/AvailabilitySection.tsx @@ -0,0 +1,408 @@ +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 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 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); + + // Check if this slot is within the template's range + return ( + template.dayOfWeek === dayIndex && + timeIndex >= startTimeIndex && + timeIndex < endTimeIndex + ); + }) || 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 templates for this day (index 0=Monday, 6=Sunday) + const dayTemplates = + user.availability?.filter((template) => { + return template.dayOfWeek === index; + }) || []; + + if (dayTemplates.length === 0) { + return null; + } + + // 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; + }; + + // 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, save current range and start new one + ranges.push({ start: rangeStart, end: rangeEnd }); + rangeStart = currentBlock; + rangeEnd = currentBlock + 30; + } + } + // Don't forget the last range + ranges.push({ start: rangeStart, end: rangeEnd }); + } + + return ( + + + {day}: + + + {ranges.map((range, i) => ( + + {minutesToTimeString(range.start)} - {minutesToTimeString(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..a61d38b8 --- /dev/null +++ b/frontend/src/components/admin/userProfile/CancerExperienceSection.tsx @@ -0,0 +1,419 @@ +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/DeactivateConfirmationModal.tsx b/frontend/src/components/admin/userProfile/DeactivateConfirmationModal.tsx new file mode 100644 index 00000000..4c3af522 --- /dev/null +++ b/frontend/src/components/admin/userProfile/DeactivateConfirmationModal.tsx @@ -0,0 +1,111 @@ +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 DeactivateConfirmationModalProps { + isOpen: boolean; + isReactivate: boolean; + onClose: () => void; + onConfirm: () => void; + isProcessing?: boolean; +} + +export function DeactivateConfirmationModal({ + isOpen, + isReactivate, + onClose, + onConfirm, + isProcessing = false, +}: DeactivateConfirmationModalProps) { + if (!isOpen) return null; + + const title = isReactivate + ? 'Are you sure you want to reactivate this account?' + : 'Are you sure you want to deactivate this account?'; + const description = isReactivate + ? 'This volunteer will become eligible for matches again.' + : 'This volunteer will no longer be eligible for matches until they are reactivated.'; + const confirmButtonText = isReactivate ? 'Reactivate Account' : 'Deactivate Account'; + + return ( + + e.stopPropagation()} + boxShadow={COLORS.shadow.lg} + > + + {/* Warning Icon */} + + + + + + + {/* Title */} + + {title} + + + {/* Description */} + + {description} + + + {/* Action Buttons */} + + + + + + + + ); +} 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/LovedOneSection.tsx b/frontend/src/components/admin/userProfile/LovedOneSection.tsx new file mode 100644 index 00000000..2b0b2bce --- /dev/null +++ b/frontend/src/components/admin/userProfile/LovedOneSection.tsx @@ -0,0 +1,478 @@ +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..6ca0cd6b --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileContent.tsx @@ -0,0 +1,315 @@ +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'; +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'; +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; + 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; + setUser: (user: UserResponse | null) => 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, + setUser, +}: ProfileContentProps) { + 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); + }; + + 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 */} + + + + {user.firstName} {user.lastName} + + + {role} + + + + {/* Only show deactivate/reactivate button for volunteers */} + {isVolunteer && ( + + )} + + + + + {/* 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/ProfileNavigation.tsx b/frontend/src/components/admin/userProfile/ProfileNavigation.tsx new file mode 100644 index 00000000..d82703d5 --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileNavigation.tsx @@ -0,0 +1,84 @@ +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..00380ce1 --- /dev/null +++ b/frontend/src/components/admin/userProfile/ProfileSummary.tsx @@ -0,0 +1,391 @@ +import React from 'react'; +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; + 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" + 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" + /> + + ) : ( + + {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" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" + /> + ) : ( + + {userData?.dateOfBirth ? formatDateLong(userData.dateOfBirth) : 'N/A'} + + )} + + {/* Phone Number */} + + + Phone Number + + {isEditing ? ( + onEditDataChange({ ...editData, phone: e.target.value })} + placeholder="Phone Number" + fontSize="sm" + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" + /> + ) : ( + + {userData?.phone || 'N/A'} + + )} + + {/* Gender */} + + + Gender + + {isEditing ? ( + + onEditDataChange({ ...editData, genderIdentity: value }) + } + placeholder="Select gender" + /> + ) : ( + + {userData?.genderIdentity || 'N/A'} + + )} + + {/* Pronouns */} + + + Pronouns + + {isEditing ? ( + onEditDataChange({ ...editData, pronouns: values })} + placeholder="Select pronouns" + /> + ) : ( + + {formatArray(userData?.pronouns)} + + )} + + {/* Time Zone */} + + + Time Zone + + {isEditing ? ( + onEditDataChange({ ...editData, timezone: value })} + placeholder="Select time zone" + /> + ) : ( + + {userData?.timezone || 'N/A'} + + )} + + {/* Ethnic or Cultural Group */} + + + Ethnic or Cultural Group + + {isEditing ? ( + onEditDataChange({ ...editData, ethnicGroup: values })} + placeholder="Select ethnic or cultural group" + /> + ) : ( + + {formatArray(userData?.ethnicGroup)} + + )} + + {/* Preferred Language */} + + + Preferred Language + + + N/A + + + {/* Marital Status */} + + + Marital Status + + {isEditing ? ( + onEditDataChange({ ...editData, maritalStatus: value })} + placeholder="Select marital status" + /> + ) : ( + + {capitalizeWords(userData?.maritalStatus)} + + )} + + {/* Parental Status */} + + + Parental Status + + {isEditing ? ( + onEditDataChange({ ...editData, hasKids: value })} + placeholder="Select parental status" + /> + ) : ( + + {capitalizeWords(userData?.hasKids)} + + )} + + + {/* Divider before Loved One fields */} + {userData?.caringForSomeone === 'yes' && ( + <> + + + + + + LO's Gender + + + {isEditing ? ( + + + onEditDataChange({ ...editData, lovedOneGenderIdentity: value }) + } + placeholder="Select loved one's gender" + /> + + ) : ( + + {userData?.lovedOneGenderIdentity || 'N/A'} + + )} + + + + + + LO's Age + + + {isEditing ? ( + onEditDataChange({ ...editData, lovedOneAge: e.target.value })} + placeholder="Loved One's Age" + fontSize="sm" + ml={4} + border="1px solid" + borderColor={COLORS.grayBorder} + borderRadius="6px" + /> + ) : ( + + {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..b8445e35 --- /dev/null +++ b/frontend/src/components/admin/userProfile/SuccessMessage.tsx @@ -0,0 +1,34 @@ +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/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/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..a1ee1796 --- /dev/null +++ b/frontend/src/hooks/useAvailabilityEditing.ts @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react'; +import { + getUserById, + createAvailability, + deleteAvailability, + AvailabilityTemplate, +} 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) { + // Convert availability templates to grid slots + user.availability.forEach((template) => { + const dayOfWeek = template.dayOfWeek; // Already 0=Mon, 6=Sun + + // 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), + }; + }; + + 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}`); + } + } + }); + } + 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; + }; + + /** + * 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 }; + }) + .sort((a, b) => { + if (a.dayIndex !== b.dayIndex) return a.dayIndex - b.dayIndex; + return a.timeIndex - b.timeIndex; + }); + + interface TemplateSlot { + dayIndex: number; + startTimeIndex: number; + endTimeIndex: number; + } + let currentRange: TemplateSlot | null = null; + + slots.forEach(({ dayIndex, timeIndex }) => { + if ( + !currentRange || + currentRange.dayIndex !== dayIndex || + currentRange.endTimeIndex !== timeIndex - 1 + ) { + if (currentRange) { + // 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; + + 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 }; + } else { + if (currentRange) { + currentRange.endTimeIndex = timeIndex; + } + } + }); + + if (currentRange !== null) { + 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; + + 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 templates; + }; + + const handleSaveAvailability = async () => { + if (!userId || !user) return; + + setIsSaving(true); + try { + // 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); + 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..62054180 --- /dev/null +++ b/frontend/src/hooks/useIntakeOptions.ts @@ -0,0 +1,25 @@ +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..7da2e722 --- /dev/null +++ b/frontend/src/hooks/useProfileEditing.ts @@ -0,0 +1,186 @@ +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..00f4918b --- /dev/null +++ b/frontend/src/hooks/useUserProfile.ts @@ -0,0 +1,26 @@ +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} + (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 ( + + + + + + ); + } + + 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 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 */} + + + + {/* Main Content */} + {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..e72d7119 --- /dev/null +++ b/frontend/src/types/userProfileTypes.ts @@ -0,0 +1,36 @@ +// 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/types/userTypes.ts b/frontend/src/types/userTypes.ts new file mode 100644 index 00000000..835f9647 --- /dev/null +++ b/frontend/src/types/userTypes.ts @@ -0,0 +1,84 @@ +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 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; + lastName: string | null; + email: string; + roleId: number; + authId: string; + approved: boolean; + active: boolean; + formStatus: string; + role: { + id: number; + name: string; + }; + userData?: UserData | null; + volunteerData?: VolunteerData | null; + availability?: AvailabilityTemplate[]; +} diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index a2a65e59..e38e355f 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -34,6 +34,26 @@ 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 date string (YYYY-MM-DD) or datetime string + * @returns Formatted date string + */ +export function formatDateLong(dateString: string): string { + // 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' }); +} + /** * Format a time to show in 12-hour format (e.g., "12:00PM") * @param dateString - ISO 8601 datetime string diff --git a/frontend/src/utils/userProfileUtils.ts b/frontend/src/utils/userProfileUtils.ts new file mode 100644 index 00000000..65d87f79 --- /dev/null +++ b/frontend/src/utils/userProfileUtils.ts @@ -0,0 +1,29 @@ +// 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", +];