diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index e142d17e..b183bb37 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,7 +16,7 @@ jobs: test: runs-on: ubuntu-latest env: - POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test + POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test strategy: matrix: @@ -65,13 +65,14 @@ jobs: - name: Set up environment variables working-directory: ./backend run: | - echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env - name: Run database migrations working-directory: ./backend run: | + export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL" pdm run alembic upgrade heads - name: Run linting @@ -118,7 +119,7 @@ jobs: runs-on: ubuntu-latest needs: test env: - POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test + POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test services: postgres: @@ -155,7 +156,7 @@ jobs: - name: Set up environment variables working-directory: ./backend run: | - echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env echo "TEST_SCRIPT_BACKEND_URL=http://localhost:8000" >> .env @@ -165,6 +166,7 @@ jobs: - name: Run database migrations working-directory: ./backend run: | + export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL" pdm run alembic upgrade heads - name: Start backend server diff --git a/backend/app/models/RankingPreference.py b/backend/app/models/RankingPreference.py index 7fb7c595..3c127064 100644 --- a/backend/app/models/RankingPreference.py +++ b/backend/app/models/RankingPreference.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, Integer +from sqlalchemy import CheckConstraint, Column, Enum, ForeignKey, Integer from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -9,9 +9,38 @@ class RankingPreference(Base): __tablename__ = "ranking_preferences" user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True) - quality_id = Column(Integer, ForeignKey("qualities.id"), primary_key=True) - rank = Column(Integer, nullable=False) # 1 = most important + # patient or caregiver (the counterpart the participant is ranking for) + target_role = Column(Enum("patient", "caregiver", name="target_role"), primary_key=True) - # Relationships + # kind of item: quality, treatment, or experience + kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind")) + + # one of these will be set based on kind + quality_id = Column(Integer, nullable=True) + treatment_id = Column(Integer, nullable=True) + experience_id = Column(Integer, nullable=True) + + # scope: self or loved_one; always required (including qualities) + scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False) + + # rank: 1 is highest + rank = Column(Integer, nullable=False, primary_key=True) + + # relationships user = relationship("User") - quality = relationship("Quality") + + __table_args__ = ( + # enforce exclusive columns by kind + CheckConstraint( + "(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL)", + name="ck_ranking_pref_quality_fields", + ), + CheckConstraint( + "(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL)", + name="ck_ranking_pref_treatment_fields", + ), + CheckConstraint( + "(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL)", + name="ck_ranking_pref_experience_fields", + ), + ) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index e737cca9..a928e546 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -1,3 +1,13 @@ -from . import auth, availability, intake, match, send_email, suggested_times, test, user +from . import auth, availability, intake, match, ranking, send_email, suggested_times, test, user -__all__ = ["auth", "availability", "intake", "match", "send_email", "suggested_times", "test", "user"] +__all__ = [ + "auth", + "availability", + "intake", + "match", + "ranking", + "send_email", + "suggested_times", + "test", + "user", +] diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 0e43f2e9..1fb1d859 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,10 +1,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +from ..models.User import User from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole from ..services.implementations.auth_service import AuthService from ..services.implementations.user_service import UserService +from ..utilities.db_utils import get_db from ..utilities.service_utils import get_auth_service, get_user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -96,3 +99,37 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_ # Log unexpected errors print(f"Unexpected error during email verification for {email}: {str(e)}") return Response(status_code=500) + + +@router.get("/me", response_model=UserCreateResponse) +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +): + """Get current authenticated user information including role""" + try: + # Get user auth_id from request state (set by auth middleware) + user_auth_id = request.state.user_id + if not user_auth_id: + raise HTTPException(status_code=401, detail="Authentication required") + + # Query user from database + user = db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserCreateResponse( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + role_id=user.role_id, + auth_id=user.auth_id, + approved=user.approved, + ) + except HTTPException: + raise + except Exception as e: + print(f"Error getting current user: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/backend/app/routes/ranking.py b/backend/app/routes/ranking.py new file mode 100644 index 00000000..d9e390b8 --- /dev/null +++ b/backend/app/routes/ranking.py @@ -0,0 +1,101 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.middleware.auth import has_roles +from app.schemas.user import UserRole +from app.services.implementations.ranking_service import RankingService +from app.utilities.db_utils import get_db + + +class StaticQualityOption(BaseModel): + quality_id: int + slug: str + label: str + allowed_scopes: List[str] | None = Field(default=None, description="Optional whitelisted scopes for this quality") + + +class DynamicOption(BaseModel): + kind: str # 'treatment' | 'experience' + id: int + name: str + scope: str # 'self' | 'loved_one' + + +class RankingOptionsResponse(BaseModel): + static_qualities: List[StaticQualityOption] + dynamic_options: List[DynamicOption] + + +router = APIRouter(prefix="/ranking", tags=["ranking"]) + + +@router.get("/options", response_model=RankingOptionsResponse) +async def get_ranking_options( + request: Request, + target: str = Query(..., pattern="^(patient|caregiver)$"), + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> RankingOptionsResponse: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + options = service.get_options(user_auth_id=user_auth_id, target=target) + return RankingOptionsResponse(**options) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class PreferenceItem(BaseModel): + kind: str # 'quality' | 'treatment' | 'experience' + id: int + scope: str # 'self' | 'loved_one' + rank: int + + +@router.put("/preferences", status_code=204) +async def put_ranking_preferences( + request: Request, + target: str = Query(..., pattern="^(patient|caregiver)$"), + items: List[PreferenceItem] = [], + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> None: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + # Convert Pydantic models to dicts + payload = [i.model_dump() for i in items] + service.save_preferences(user_auth_id=user_auth_id, target=target, items=payload) + return None + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class CaseResponse(BaseModel): + case: str + has_blood_cancer: str | None = None + caring_for_someone: str | None = None + + +@router.get("/case", response_model=CaseResponse) +async def get_participant_case( + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> CaseResponse: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + result = service.get_case(user_auth_id) + return CaseResponse(**result) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/server.py b/backend/app/server.py index 6be61f43..eb1f695c 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -8,7 +8,7 @@ from . import models from .middleware.auth_middleware import AuthMiddleware -from .routes import auth, availability, intake, match, send_email, suggested_times, test, user +from .routes import auth, availability, intake, match, ranking, send_email, suggested_times, test, user from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -68,6 +68,7 @@ async def lifespan(_: FastAPI): app.include_router(suggested_times.router) app.include_router(match.router) app.include_router(intake.router) +app.include_router(ranking.router) app.include_router(send_email.router) app.include_router(test.router) diff --git a/backend/app/services/implementations/ranking_service.py b/backend/app/services/implementations/ranking_service.py new file mode 100644 index 00000000..ccb076b8 --- /dev/null +++ b/backend/app/services/implementations/ranking_service.py @@ -0,0 +1,208 @@ +from typing import Dict, List + +from sqlalchemy.orm import Session + +from app.models import Quality, User, UserData +from app.models.RankingPreference import RankingPreference + + +class RankingService: + def __init__(self, db: Session): + self.db = db + + def _load_user_and_data(self, user_auth_id: str) -> UserData | None: + user = self.db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + return None + return self.db.query(UserData).filter(UserData.user_id == user.id).first() + + def _infer_case(self, data: UserData) -> Dict[str, bool]: + has_cancer = (data.has_blood_cancer or "").lower() == "yes" + caring = (data.caring_for_someone or "").lower() == "yes" + return { + "patient": not caring, + "caregiver_with_cancer": has_cancer and caring, + "caregiver_without_cancer": (not has_cancer) and caring, + } + + def _static_qualities(self, data: UserData, target: str, case: Dict[str, bool]) -> List[Dict]: + qualities = self.db.query(Quality).order_by(Quality.id.asc()).all() + items: List[Dict] = [] + # Determine allowed_scopes for same_diagnosis + allow_self_diag = False + allow_loved_diag = False + if target == "patient": + if case["patient"] and data.diagnosis: + allow_self_diag = True + if (case["caregiver_with_cancer"] or case["caregiver_without_cancer"]) and data.loved_one_diagnosis: + allow_loved_diag = True + else: # target == caregiver (two-column) + if data.loved_one_diagnosis: + allow_loved_diag = True + if case["caregiver_with_cancer"] and data.diagnosis: + allow_self_diag = True + + for q in qualities: + # Default allowed scopes by slug + # Only age, gender identity, and diagnosis may include loved_one scope + if q.slug == "same_age": + allowed_scopes = ["self", "loved_one"] + elif q.slug == "same_gender_identity": + allowed_scopes = ["self", "loved_one"] + elif q.slug == "same_ethnic_or_cultural_group": + allowed_scopes = ["self"] + elif q.slug == "same_marital_status": + allowed_scopes = ["self"] + elif q.slug == "same_parental_status": + allowed_scopes = ["self"] + elif q.slug == "same_diagnosis": + scopes: List[str] = [] + if allow_self_diag: + scopes.append("self") + if allow_loved_diag: + scopes.append("loved_one") + allowed_scopes = scopes if scopes else [] + else: + # Any unexpected quality defaults to self only + allowed_scopes = ["self"] + items.append( + { + "quality_id": q.id, + "slug": q.slug, + "label": q.label, + "allowed_scopes": allowed_scopes, + } + ) + return items + + def _dynamic_options(self, data: UserData, target: str, case: Dict[str, bool]) -> List[Dict]: + options: List[Dict] = [] + + def add_txs(txs, scope: str): + for t in txs: + options.append({"kind": "treatment", "id": t.id, "name": getattr(t, "name", str(t.id)), "scope": scope}) + + def add_exps(exps, scope: str): + for e in exps: + options.append( + {"kind": "experience", "id": e.id, "name": getattr(e, "name", str(e.id)), "scope": scope} + ) + + if target == "patient": + if case["patient"]: + add_txs(data.treatments or [], "self") + add_exps(data.experiences or [], "self") + else: + add_txs(data.loved_one_treatments or [], "loved_one") + add_exps(data.loved_one_experiences or [], "loved_one") + else: # caregiver target + add_txs(data.treatments or [], "self") + add_exps(data.experiences or [], "self") + add_txs(data.loved_one_treatments or [], "loved_one") + add_exps(data.loved_one_experiences or [], "loved_one") + # de-duplicate by (kind,id,scope) + seen = set() + deduped: List[Dict] = [] + for opt in options: + key = (opt["kind"], opt["id"], opt["scope"]) + if key in seen: + continue + seen.add(key) + deduped.append(opt) + # sort by name for stable UI + deduped.sort(key=lambda o: (o["scope"], o["kind"], o["name"].lower())) + return deduped + + def get_options(self, user_auth_id: str, target: str) -> Dict: + data = self._load_user_and_data(user_auth_id) + if not data: + # Return just static qualities if no data + dummy_case = {"patient": False, "caregiver_with_cancer": False, "caregiver_without_cancer": False} + return { + "static_qualities": self._static_qualities(UserData(), target, dummy_case), + "dynamic_options": [], + } + case = self._infer_case(data) + return { + "static_qualities": self._static_qualities(data, target, case), + "dynamic_options": self._dynamic_options(data, target, case), + } + + def get_case(self, user_auth_id: str) -> Dict: + """Return inferred participant case and raw flags from UserData.""" + data = self._load_user_and_data(user_auth_id) + if not data: + return { + "case": "caregiver_without_cancer", # safe default if missing + "has_blood_cancer": None, + "caring_for_someone": None, + } + inferred = self._infer_case(data) + case_label = ( + "patient" + if inferred["patient"] + else ("caregiver_with_cancer" if inferred["caregiver_with_cancer"] else "caregiver_without_cancer") + ) + return { + "case": case_label, + "has_blood_cancer": data.has_blood_cancer, + "caring_for_someone": data.caring_for_someone, + } + + # Preferences persistence + def save_preferences(self, user_auth_id: str, target: str, items: List[Dict]) -> None: + user = self.db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + raise ValueError("User not found") + + # Validate and normalize + normalized: List[RankingPreference] = [] + if len(items) > 5: + raise ValueError("A maximum of 5 ranking items is allowed") + + seen_ranks: set[int] = set() + seen_keys: set[tuple] = set() + for item in items: + kind = item.get("kind") + scope = item.get("scope") + rank = int(item.get("rank")) + item_id = item.get("id") + + if kind not in ("quality", "treatment", "experience"): + raise ValueError(f"Invalid kind: {kind}") + if scope not in ("self", "loved_one"): + raise ValueError(f"Invalid scope: {scope}") + if rank < 1 or rank > 5: + raise ValueError("rank must be between 1 and 5") + if rank in seen_ranks: + raise ValueError("ranks must be unique") + seen_ranks.add(rank) + if not isinstance(item_id, int): + raise ValueError("id must be an integer") + + key = (kind, item_id, scope) + if key in seen_keys: + raise ValueError("duplicate item in payload") + seen_keys.add(key) + + pref = RankingPreference( + user_id=user.id, + target_role=target, + kind=kind, + quality_id=item_id if kind == "quality" else None, + treatment_id=item_id if kind == "treatment" else None, + experience_id=item_id if kind == "experience" else None, + scope=scope, + rank=rank, + ) + normalized.append(pref) + + # Overwrite strategy: delete existing rows for (user, target), then bulk insert + ( + self.db.query(RankingPreference) + .filter(RankingPreference.user_id == user.id, RankingPreference.target_role == target) + .delete(synchronize_session=False) + ) + if normalized: + self.db.bulk_save_objects(normalized) + self.db.commit() diff --git a/backend/app/utilities/db_utils.py b/backend/app/utilities/db_utils.py index 986bdbcb..6edaa687 100644 --- a/backend/app/utilities/db_utils.py +++ b/backend/app/utilities/db_utils.py @@ -6,7 +6,13 @@ load_dotenv() -DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError( + "POSTGRES_TEST_DATABASE_URL is not set. " + "Set one of them to a valid Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + ) engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/utilities/service_utils.py b/backend/app/utilities/service_utils.py index 46893b6e..a362a99c 100644 --- a/backend/app/utilities/service_utils.py +++ b/backend/app/utilities/service_utils.py @@ -12,6 +12,6 @@ def get_user_service(db: Session = Depends(get_db)): return UserService(db) -def get_auth_service(db: Session = Depends(get_db)): +def get_auth_service(user_service: UserService = Depends(get_user_service)): logger = logging.getLogger(__name__) - return AuthService(logger=logger, user_service=UserService(db)) + return AuthService(logger=logger, user_service=user_service) diff --git a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py new file mode 100644 index 00000000..e28b27d5 --- /dev/null +++ b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py @@ -0,0 +1,135 @@ +"""ranking: unified preferences table + seed qualities + +Revision ID: 9d7570569af9 +Revises: fb0638c24174 +Create Date: 2025-08-31 20:49:12.042730 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "9d7570569af9" +down_revision: Union[str, None] = "fb0638c24174" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop legacy ranking_preferences table if it exists + bind = op.get_bind() + inspector = sa.inspect(bind) + if "ranking_preferences" in inspector.get_table_names(): + op.drop_table("ranking_preferences") + + # Create ENUM types + target_role_enum = postgresql.ENUM("patient", "caregiver", name="target_role", create_type=False) + kind_enum = postgresql.ENUM("quality", "treatment", "experience", name="ranking_kind", create_type=False) + scope_enum = postgresql.ENUM("self", "loved_one", name="ranking_scope", create_type=False) + target_role_enum.create(bind, checkfirst=True) + kind_enum.create(bind, checkfirst=True) + scope_enum.create(bind, checkfirst=True) + + # Create unified ranking_preferences table + op.create_table( + "ranking_preferences", + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False), + sa.Column("target_role", target_role_enum, nullable=False), + sa.Column("kind", kind_enum, nullable=False), + sa.Column("quality_id", sa.Integer(), nullable=True), + sa.Column("treatment_id", sa.Integer(), nullable=True), + sa.Column("experience_id", sa.Integer(), nullable=True), + sa.Column("scope", scope_enum, nullable=False), + sa.Column("rank", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("user_id", "target_role", "rank"), + ) + + # Add check constraints to enforce exclusivity by kind + op.create_check_constraint( + "ck_ranking_pref_quality_fields", + "ranking_preferences", + "(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", + ) + op.create_check_constraint( + "ck_ranking_pref_treatment_fields", + "ranking_preferences", + "(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", + ) + op.create_check_constraint( + "ck_ranking_pref_experience_fields", + "ranking_preferences", + "(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL AND scope IS NOT NULL)", + ) + + # Helpful indexes + op.create_index("ix_ranking_pref_user_target", "ranking_preferences", ["user_id", "target_role"]) + op.create_index("ix_ranking_pref_user_kind", "ranking_preferences", ["user_id", "kind"]) + + # Remove any legacy/static qualities not in the approved set (idempotent) + op.execute( + """ + DELETE FROM qualities + WHERE slug NOT IN ( + 'same_age', + 'same_gender_identity', + 'same_ethnic_or_cultural_group', + 'same_marital_status', + 'same_parental_status', + 'same_diagnosis' + ); + """ + ) + + # Seed qualities (idempotent) using slug/label pairs + # Using plain SQL for ON CONFLICT DO NOTHING + qualities = [ + ("same_age", "the same age as"), + ("same_gender_identity", "the same gender identity as"), + ("same_ethnic_or_cultural_group", "the same ethnic or cultural group as"), + ("same_marital_status", "the same marital status as"), + ("same_parental_status", "the same parental status as"), + ("same_diagnosis", "the same diagnosis as"), + ] + conn = op.get_bind() + # Ensure the sequence is aligned to current MAX(id) to avoid PK conflicts + conn.execute( + sa.text("SELECT setval(pg_get_serial_sequence('qualities','id'), COALESCE((SELECT MAX(id) FROM qualities), 0))") + ) + # First update labels for any existing slugs + for slug, label in qualities: + conn.execute( + sa.text("UPDATE qualities SET label = :label WHERE slug = :slug"), + {"slug": slug, "label": label}, + ) + # Then insert any missing slugs + for slug, label in qualities: + conn.execute( + sa.text( + "INSERT INTO qualities (slug, label) " + "SELECT :slug, :label WHERE NOT EXISTS (SELECT 1 FROM qualities WHERE slug = :slug)" + ), + {"slug": slug, "label": label}, + ) + + +def downgrade() -> None: + # Drop unified table + op.drop_index("ix_ranking_pref_user_kind", table_name="ranking_preferences") + op.drop_index("ix_ranking_pref_user_target", table_name="ranking_preferences") + op.drop_constraint("ck_ranking_pref_experience_fields", "ranking_preferences", type_="check") + op.drop_constraint("ck_ranking_pref_treatment_fields", "ranking_preferences", type_="check") + op.drop_constraint("ck_ranking_pref_quality_fields", "ranking_preferences", type_="check") + op.drop_table("ranking_preferences") + + # Drop ENUMs if present + bind = op.get_bind() + target_role_enum = postgresql.ENUM("patient", "caregiver", name="target_role") + kind_enum = postgresql.ENUM("quality", "treatment", "experience", name="ranking_kind") + scope_enum = postgresql.ENUM("self", "loved_one", name="ranking_scope") + scope_enum.drop(bind, checkfirst=True) + kind_enum.drop(bind, checkfirst=True) + target_role_enum.drop(bind, checkfirst=True) diff --git a/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py new file mode 100644 index 00000000..88083102 --- /dev/null +++ b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py @@ -0,0 +1,23 @@ +"""merge heads before ranking work + +Revision ID: fb0638c24174 +Revises: 7b797eccb3aa, 88c4cf2a6bd2 +Create Date: 2025-08-31 20:48:25.460360 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "fb0638c24174" +down_revision: Union[str, None] = ("7b797eccb3aa", "88c4cf2a6bd2") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py new file mode 100644 index 00000000..a26ac809 --- /dev/null +++ b/backend/tests/unit/test_ranking_service.py @@ -0,0 +1,272 @@ +import os +from typing import List + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from app.models import Experience, Quality, Role, Treatment, User, UserData +from app.schemas.user import UserRole +from app.services.implementations.ranking_service import RankingService + +# 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" + ) +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session() -> Session: + session = TestingSessionLocal() + try: + # FK-safe cleanup of related tables used in this test module + session.execute( + text( + "TRUNCATE TABLE ranking_preferences, user_loved_one_experiences, user_loved_one_treatments, " + "user_experiences, user_treatments, user_data, users RESTART IDENTITY CASCADE" + ) + ) + session.commit() + + # Ensure roles exist + existing = {r.id for r in session.query(Role).all()} + roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for r in roles: + if r.id not in existing: + session.add(r) + session.commit() + + # Qualities should have been seeded by migrations; assert presence + assert session.query(Quality).count() >= 6 + + # Ensure sequences are aligned after seeding (avoid PK collisions when inserting) + session.execute( + text( + "SELECT setval(pg_get_serial_sequence('treatments','id'), COALESCE((SELECT MAX(id) FROM treatments), 0))" + ) + ) + session.execute( + text( + "SELECT setval(pg_get_serial_sequence('experiences','id'), COALESCE((SELECT MAX(id) FROM experiences), 0))" + ) + ) + session.commit() + + yield session + finally: + session.rollback() + session.close() + + +def _add_user_data( + session: Session, + *, + auth_id: str, + role_id: int = 1, + has_blood_cancer: str = "no", + caring_for_someone: str = "no", + diagnosis: str | None = None, + loved_one_diagnosis: str | None = None, + self_treatments: List[str] | None = None, + self_experiences: List[str] | None = None, + loved_treatments: List[str] | None = None, + loved_experiences: List[str] | None = None, +) -> User: + user = User(first_name="T", last_name="U", email=f"{auth_id}@ex.com", role_id=role_id, auth_id=auth_id) + session.add(user) + session.commit() + + data = UserData( + user_id=user.id, + has_blood_cancer=has_blood_cancer, + caring_for_someone=caring_for_someone, + diagnosis=diagnosis, + loved_one_diagnosis=loved_one_diagnosis, + ) + session.add(data) + session.flush() + + def get_or_create_treatment(name: str) -> Treatment: + t = session.query(Treatment).filter(Treatment.name == name).first() + if t: + return t + for _ in range(2): + try: + t = Treatment(name=name) + session.add(t) + session.flush() + return t + except IntegrityError: + session.rollback() + # Sequence collision consumed an id; retry insert + t = session.query(Treatment).filter(Treatment.name == name).first() + if t: + return t + # Final attempt to read existing + return session.query(Treatment).filter(Treatment.name == name).first() + + def get_or_create_experience(name: str) -> Experience: + e = session.query(Experience).filter(Experience.name == name).first() + if e: + return e + for _ in range(2): + try: + e = Experience(name=name) + session.add(e) + session.flush() + return e + except IntegrityError: + session.rollback() + e = session.query(Experience).filter(Experience.name == name).first() + if e: + return e + return session.query(Experience).filter(Experience.name == name).first() + + for n in self_treatments or []: + data.treatments.append(get_or_create_treatment(n)) + for n in self_experiences or []: + data.experiences.append(get_or_create_experience(n)) + for n in loved_treatments or []: + data.loved_one_treatments.append(get_or_create_treatment(n)) + for n in loved_experiences or []: + data.loved_one_experiences.append(get_or_create_experience(n)) + + session.commit() + return user + + +@pytest.mark.asyncio +async def test_options_patient_participant_target_patient(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_patient", + has_blood_cancer="yes", + caring_for_someone="no", + diagnosis="AML", + self_treatments=["Chemotherapy"], + self_experiences=["Fatigue"], + ) + + service = RankingService(db_session) + res = service.get_options(user_auth_id=user.auth_id, target="patient") + + # same_diagnosis should allow self only + same_diag = next(q for q in res["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag["allowed_scopes"] == ["self"] + + # dynamic should include self items + scopes = {o["scope"] for o in res["dynamic_options"]} + assert scopes == {"self"} + + +@pytest.mark.asyncio +async def test_options_caregiver_without_cancer(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_cg_no_cancer", + has_blood_cancer="no", + caring_for_someone="yes", + self_treatments=["Oral Chemotherapy"], + self_experiences=["Caregiver Fatigue"], + loved_one_diagnosis="CLL", + loved_treatments=["Immunotherapy"], + loved_experiences=["Anxiety"], + ) + + service = RankingService(db_session) + # target=patient → loved_one options only; same_diagnosis loved_one only + res_p = service.get_options(user_auth_id=user.auth_id, target="patient") + same_diag_p = next(q for q in res_p["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_p["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_p["dynamic_options"]} == {"loved_one"} + + # target=caregiver → both scopes; same_diagnosis loved_one only + res_c = service.get_options(user_auth_id=user.auth_id, target="caregiver") + same_diag_c = next(q for q in res_c["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_c["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_c["dynamic_options"]} == {"self", "loved_one"} + + +@pytest.mark.asyncio +async def test_options_caregiver_with_cancer(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_cg_with_cancer", + has_blood_cancer="yes", + caring_for_someone="yes", + diagnosis="MDS", + loved_one_diagnosis="MM", + self_treatments=["Radiation Therapy"], + self_experiences=["PTSD"], + loved_treatments=["Watch and Wait / Active Surveillance"], + loved_experiences=["Communication Challenges"], + ) + + service = RankingService(db_session) + # target=patient → loved_one options only; same_diagnosis loved_one only + res_p = service.get_options(user_auth_id=user.auth_id, target="patient") + same_diag_p = next(q for q in res_p["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_p["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_p["dynamic_options"]} == {"loved_one"} + + # target=caregiver → both scopes; same_diagnosis includes both scopes + res_c = service.get_options(user_auth_id=user.auth_id, target="caregiver") + same_diag_c = next(q for q in res_c["static_qualities"] if q["slug"] == "same_diagnosis") + assert set(same_diag_c["allowed_scopes"]) == {"self", "loved_one"} + assert {o["scope"] for o in res_c["dynamic_options"]} == {"self", "loved_one"} + + +def test_save_preferences_validation(db_session: Session): + # Setup a participant with some options + user = _add_user_data( + db_session, + auth_id="auth_validate", + has_blood_cancer="no", + caring_for_someone="yes", + loved_treatments=["Immunotherapy"], + loved_experiences=["Anxiety"], + ) + service = RankingService(db_session) + + # More than 5 items + too_many = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 2, "scope": "self", "rank": 2}, + {"kind": "quality", "id": 3, "scope": "self", "rank": 3}, + {"kind": "quality", "id": 4, "scope": "self", "rank": 4}, + {"kind": "quality", "id": 5, "scope": "self", "rank": 5}, + {"kind": "quality", "id": 6, "scope": "self", "rank": 5}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=too_many) + + # Duplicate ranks + dup_ranks = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 2, "scope": "self", "rank": 1}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=dup_ranks) + + # Rank out of bounds + bad_rank = [{"kind": "quality", "id": 1, "scope": "self", "rank": 6}] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=bad_rank) + + # Duplicate items + dup_items = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 1, "scope": "self", "rank": 2}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=dup_items) diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 643f22ac..05a525eb 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -19,11 +19,11 @@ from app.services.implementations.user_service import UserService # Test DB Configuration - Always require Postgres for full parity -POSTGRES_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +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@localhost:5432/llsc" + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" ) engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -537,7 +537,7 @@ async def test_update_user_by_id(db_session): # Error case tests @pytest.mark.asyncio async def test_delete_nonexistent_user_by_email(db_session): - """Test deleting a non-existent user by email""" + """Test deleting a non-existent user""" user_service = UserService(db_session) with pytest.raises(HTTPException) as exc_info: await user_service.delete_user_by_email("nonexistent@example.com") @@ -546,7 +546,7 @@ async def test_delete_nonexistent_user_by_email(db_session): @pytest.mark.asyncio async def test_delete_nonexistent_user_by_id(db_session): - """Test deleting a non-existent user by ID""" + """Test deleting a non-existent user""" user_service = UserService(db_session) with pytest.raises(HTTPException) as exc_info: await user_service.delete_user_by_id("00000000-0000-0000-0000-000000000000") @@ -563,7 +563,7 @@ async def test_get_nonexistent_user_by_id(db_session): def test_get_nonexistent_user_by_email(db_session): - """Test getting a non-existent user by email""" + """Test getting user by email""" user_service = UserService(db_session) with pytest.raises(ValueError) as exc_info: user_service.get_user_by_email("nonexistent@example.com") diff --git a/frontend/src/components/auth/AuthLoadingSkeleton.tsx b/frontend/src/components/auth/AuthLoadingSkeleton.tsx new file mode 100644 index 00000000..814b4213 --- /dev/null +++ b/frontend/src/components/auth/AuthLoadingSkeleton.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Box, Flex, Spinner, Text } from '@chakra-ui/react'; + +/** + * Simple loading component for protected pages + */ +export const AuthLoadingSkeleton: React.FC = () => { + return ( + + + + + Loading... + + + + ); +}; diff --git a/frontend/src/components/auth/ProtectedPage.tsx b/frontend/src/components/auth/ProtectedPage.tsx new file mode 100644 index 00000000..c13010af --- /dev/null +++ b/frontend/src/components/auth/ProtectedPage.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { UserRole } from '@/types/authTypes'; +import { useProtectedRoute } from '@/hooks/useProtectedRoute'; + +interface ProtectedPageProps { + allowedRoles: UserRole[]; + children: React.ReactNode; +} + +/** + * Wrapper component that handles auth protection logic for pages + * Eliminates the need to repeat auth checks in every protected page + */ +export const ProtectedPage: React.FC = ({ allowedRoles, children }) => { + const { authorized, LoadingComponent } = useProtectedRoute(allowedRoles); + + // Show loading skeleton while checking auth + if (LoadingComponent) { + return ; + } + + // This will never be reached due to redirects in the hook, but good for safety + if (!authorized) { + return null; + } + + return <>{children}; +}; diff --git a/frontend/src/components/intake/thank-you-screen.tsx b/frontend/src/components/intake/thank-you-screen.tsx index 9d1d1fab..5168d743 100644 --- a/frontend/src/components/intake/thank-you-screen.tsx +++ b/frontend/src/components/intake/thank-you-screen.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Heading, Text, VStack } from '@chakra-ui/react'; -import { COLORS, IntakeFormData } from '@/constants/form'; +import { COLORS } from '@/constants/form'; // Check mark icon component const CheckMarkIcon: React.FC = () => ( @@ -27,11 +27,7 @@ const CheckMarkIcon: React.FC = () => ( ); -interface ThankYouScreenProps { - formData?: IntakeFormData; -} - -export function ThankYouScreen({ formData }: ThankYouScreenProps) { +export function ThankYouScreen() { return ( - You will receive a confirmation email. A staff member will call you within 4-5 business + You will receive a confirmation email. A staff member will call you within 1-2 business days to better understand your match preferences. For any inquiries, please reach us at{' '} - placeholder@placeholder.com + FirstConnections@lls.org - . + . Please note LLSC's working days are Monday-Thursday. - - {/* Debug: Display form data */} - {formData && ( - - - Collected Form Data (Debug) - - - {JSON.stringify(formData, null, 2)} - - - )} diff --git a/frontend/src/components/ranking/caregiver-matching-form.tsx b/frontend/src/components/ranking/caregiver-matching-form.tsx new file mode 100644 index 00000000..895a0132 --- /dev/null +++ b/frontend/src/components/ranking/caregiver-matching-form.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; + +import { CustomRadio } from '@/components/CustomRadio'; +import { COLORS } from '@/constants/form'; + +interface CaregiverMatchingFormProps { + volunteerType: string; + onVolunteerTypeChange: (type: string) => void; + onNext: (type: string) => void; +} + +export function CaregiverMatchingForm({ + volunteerType, + onVolunteerTypeChange, + onNext, +}: CaregiverMatchingFormProps) { + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Your volunteer + + + This information will be used in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + I would like a volunteer that... + + + + onVolunteerTypeChange(value)} + > + + has a similar diagnosis + + + + onVolunteerTypeChange(value)} + > + + is caring for a loved one with blood cancer + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/ranking/caregiver-qualities-form.tsx b/frontend/src/components/ranking/caregiver-qualities-form.tsx new file mode 100644 index 00000000..3a041637 --- /dev/null +++ b/frontend/src/components/ranking/caregiver-qualities-form.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; + +const CAREGIVER_QUALITIES = [ + 'the same age as my loved one', + 'the same gender identity as my loved one', + 'the same diagnosis as my loved one', + 'experience with returning to school or work during/after treatment', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with PTSD', + 'experience with Fertility Issues', +]; + +type DisplayOption = { key: string; label: string }; + +interface CaregiverQualitiesFormProps { + selectedQualities: string[]; + onQualityToggle: (key: string) => void; + onNext: () => void; + options?: DisplayOption[]; +} + +export function CaregiverQualitiesForm({ + selectedQualities, + onQualityToggle, + onNext, + options, +}: CaregiverQualitiesFormProps) { + const qualities: DisplayOption[] = + options && options.length > 0 + ? options + : CAREGIVER_QUALITIES.map((label) => ({ key: label, label })); + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + I would prefer a volunteer with... + + + You can select a maximum of 5. Please select at least one quality. + + + + {qualities.map((opt) => ( + onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && selectedQualities.length >= 5} + > + + {opt.label} + + + ))} + + + + + + + + + + ); +} diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx new file mode 100644 index 00000000..37a43358 --- /dev/null +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { DragIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; + +interface CaregiverRankingFormProps { + rankedPreferences: string[]; + onMoveItem: (fromIndex: number, toIndex: number) => void; + onSubmit: () => void; + itemScopes?: Array<'self' | 'loved_one'>; + itemKinds?: Array<'quality' | 'treatment' | 'experience'>; +} + +export function CaregiverRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit, + itemScopes, + itemKinds, +}: CaregiverRankingFormProps) { + const [draggedIndex, setDraggedIndex] = React.useState(null); + const [dropTargetIndex, setDropTargetIndex] = React.useState(null); + + const renderStatement = (index: number, label: string) => { + const kind = itemKinds?.[index]; + const scope = itemScopes?.[index]; + const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const isLovedOneDynamic = + (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; + const prefix = isLovedOneQuality + ? 'I would prefer a volunteer whose loved one is ' + : isLovedOneDynamic + ? 'I would prefer a volunteer whose loved one has ' + : 'I would prefer a volunteer with '; + return ( + <> + {prefix} + + {label} + + + ); + }; + + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', index.toString()); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropTargetIndex(index); + }; + + const handleDragLeave = () => { + setDropTargetIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== dropIndex) { + onMoveItem(draggedIndex, dropIndex); + } + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Ranking Match Preferences + + + This information will be used to match you with a suitable volunteer. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + Rank the following statements in the order that you agree with them: + + + 1 is most agreed, 5 is least agreed. + + + + {rankedPreferences.map((statement, index) => { + const isDragging = draggedIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + + + {index + 1}. + + + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + _hover={{ + borderColor: COLORS.teal, + boxShadow: `0 0 0 1px ${COLORS.teal}20`, + bg: isDragging ? '#e5e7eb' : '#f3f4f6', + }} + > + + + + + + {renderStatement(index, statement)} + + + + ); + })} + + + + + + + + + + ); +} diff --git a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx new file mode 100644 index 00000000..c8516b8c --- /dev/null +++ b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx @@ -0,0 +1,219 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text, SimpleGrid } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; + +interface CaregiverTwoColumnQualitiesFormProps { + selectedQualities: string[]; + onQualityToggle: (key: string) => void; + onNext: () => void; + leftOptions?: { key: string; label: string }[]; + rightOptions?: { key: string; label: string }[]; +} + +// Left column options – The volunteer is/has… ("…as me" phrasing) +const VOLUNTEER_OPTIONS = [ + 'the same age as me', + 'the same gender identity as me', + 'the same ethnic or cultural group as me', + 'the same marital status as me', + 'the same parental status as me', + 'the same diagnosis as me', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +// Right column options – Their loved one is/has… ("…as my loved one" phrasing) +const LOVED_ONE_OPTIONS = [ + 'the same age as my loved one', + 'the same gender identity as my loved one', + 'the same diagnosis as my loved one', + 'experience with Oral Chemotherapy', + 'experience with Radiation Therapy', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +export function CaregiverTwoColumnQualitiesForm({ + selectedQualities, + onQualityToggle, + onNext, + leftOptions, + rightOptions, +}: CaregiverTwoColumnQualitiesFormProps) { + const maxSelected = 5; + const reachedMax = selectedQualities.length >= maxSelected; + const volunteerOptions = + leftOptions && leftOptions.length > 0 + ? leftOptions + : VOLUNTEER_OPTIONS.map((label) => ({ key: label, label })); + const lovedOneOptions = + rightOptions && rightOptions.length > 0 + ? rightOptions + : LOVED_ONE_OPTIONS.map((label) => ({ key: label, label })); + + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + I would prefer that... + + + You can select a maximum of 5 across both categories. Please select at least one + quality. + + + + + + The volunteer is/has... + + + {volunteerOptions.map((opt) => ( + onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && reachedMax} + > + + {opt.label} + + + ))} + + + + + + Their loved one is/has... + + + {lovedOneOptions.map((opt) => ( + onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && reachedMax} + > + + {opt.label} + + + ))} + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/ranking/index.ts b/frontend/src/components/ranking/index.ts new file mode 100644 index 00000000..87b4b089 --- /dev/null +++ b/frontend/src/components/ranking/index.ts @@ -0,0 +1,6 @@ +export { VolunteerMatchingForm } from './volunteer-matching-form'; +export { VolunteerRankingForm } from './volunteer-ranking-form'; +export { CaregiverMatchingForm } from './caregiver-matching-form'; +export { CaregiverQualitiesForm } from './caregiver-qualities-form'; +export { CaregiverRankingForm } from './caregiver-ranking-form'; +export { CaregiverTwoColumnQualitiesForm } from './caregiver-two-column-qualities-form'; diff --git a/frontend/src/components/ranking/volunteer-matching-form.tsx b/frontend/src/components/ranking/volunteer-matching-form.tsx new file mode 100644 index 00000000..bf3da999 --- /dev/null +++ b/frontend/src/components/ranking/volunteer-matching-form.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; +const MATCHING_QUALITIES = [ + 'the same age as me', + 'the same gender identity as me', + 'the same ethnic or cultural group as me', + 'the same marital status as me', + 'the same parental status as me', + 'the same diagnosis as me', + 'experience with Oral Chemotherapy', + 'experience with Radiation Therapy', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +type DisplayOption = { key: string; label: string }; + +interface VolunteerMatchingFormProps { + selectedQualities: string[]; // stores option keys + onQualityToggle: (key: string) => void; + onNext: () => void; + options?: DisplayOption[]; +} + +export function VolunteerMatchingForm({ + selectedQualities, + onQualityToggle, + onNext, + options, +}: VolunteerMatchingFormProps) { + const qualities: DisplayOption[] = + options && options.length > 0 + ? options + : MATCHING_QUALITIES.map((label) => ({ key: label, label })); + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + I would prefer a volunteer with... + + + You can select a maximum of 5. Please select at least one quality. + + + + {qualities.map((opt) => ( + onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && selectedQualities.length >= 5} + > + + {opt.label} + + + ))} + + + + + + + + + + ); +} diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx new file mode 100644 index 00000000..a1491918 --- /dev/null +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { DragIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; + +interface VolunteerRankingFormProps { + rankedPreferences: string[]; + onMoveItem: (fromIndex: number, toIndex: number) => void; + onSubmit: () => void; + itemScopes?: Array<'self' | 'loved_one'>; + itemKinds?: Array<'quality' | 'treatment' | 'experience'>; +} + +export function VolunteerRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit, + itemScopes, + itemKinds, +}: VolunteerRankingFormProps) { + const [draggedIndex, setDraggedIndex] = React.useState(null); + const [dropTargetIndex, setDropTargetIndex] = React.useState(null); + + const renderStatement = (index: number, label: string) => { + const kind = itemKinds?.[index]; + const scope = itemScopes?.[index]; + const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const isLovedOneDynamic = + (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; + const prefix = isLovedOneQuality + ? 'I would prefer a volunteer whose loved one is ' + : isLovedOneDynamic + ? 'I would prefer a volunteer whose loved one has ' + : 'I would prefer a volunteer with '; + return ( + <> + {prefix} + + {label} + + + ); + }; + + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', index.toString()); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropTargetIndex(index); + }; + + const handleDragLeave = () => { + setDropTargetIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== dropIndex) { + onMoveItem(draggedIndex, dropIndex); + } + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDropTargetIndex(null); + }; + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + Ranking Match Preferences + + + This information will be used to match you with a suitable volunteer. + + + Note that your volunteer is guaranteed to speak your language and have the same + availability. + + + + + + Rank the following statements in the order that you agree with them: + + + 1 is most agreed, 5 is least agreed. + + + + {rankedPreferences.map((statement, index) => { + const isDragging = draggedIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + + + {index + 1}. + + + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + _hover={{ + borderColor: COLORS.teal, + boxShadow: `0 0 0 1px ${COLORS.teal}20`, + bg: isDragging ? '#e5e7eb' : '#f3f4f6', + }} + > + + + + + + {renderStatement(index, statement)} + + + + ); + })} + + + + + + + + + + ); +} diff --git a/frontend/src/components/ui/checkmark-icon.tsx b/frontend/src/components/ui/checkmark-icon.tsx new file mode 100644 index 00000000..0c642cf3 --- /dev/null +++ b/frontend/src/components/ui/checkmark-icon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +export const CheckMarkIcon: React.FC = () => ( + + + + + +); diff --git a/frontend/src/components/ui/drag-icon.tsx b/frontend/src/components/ui/drag-icon.tsx new file mode 100644 index 00000000..9c7fef35 --- /dev/null +++ b/frontend/src/components/ui/drag-icon.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { COLORS } from '@/constants/form'; + +export const DragIcon: React.FC = () => ( + + + + + + + + +); diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..a48bb5b9 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { UserIcon } from './user-icon'; +export { CheckMarkIcon } from './checkmark-icon'; +export { DragIcon } from './drag-icon'; +export { WelcomeScreen } from './welcome-screen'; diff --git a/frontend/src/components/ui/user-icon.tsx b/frontend/src/components/ui/user-icon.tsx new file mode 100644 index 00000000..8fdc8fa7 --- /dev/null +++ b/frontend/src/components/ui/user-icon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +export const UserIcon: React.FC = () => ( + + + + + + +); diff --git a/frontend/src/components/ui/welcome-screen.tsx b/frontend/src/components/ui/welcome-screen.tsx new file mode 100644 index 00000000..ab1c063b --- /dev/null +++ b/frontend/src/components/ui/welcome-screen.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Box, Heading, Text, Button } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface WelcomeScreenProps { + icon: React.ReactNode; + title: string; + description: string; + buttonText?: string; + onContinue: () => void; +} + +export const WelcomeScreen: React.FC = ({ + icon, + title, + description, + buttonText = 'Continue', + onContinue, +}) => ( + + + {icon} + + + {title} + + + + + + + +); diff --git a/frontend/src/constants/form.ts b/frontend/src/constants/form.ts index 091a5297..ebfdbdb3 100644 --- a/frontend/src/constants/form.ts +++ b/frontend/src/constants/form.ts @@ -2,7 +2,7 @@ export const COLORS = { veniceBlue: '#1d3448', fieldGray: '#6b7280', - teal: '#0d7377', + teal: '#056067', lightTeal: '#e6f7f7', lightGray: '#f3f4f6', progressTeal: '#5eead4', diff --git a/frontend/src/hooks/useAuthGuard.ts b/frontend/src/hooks/useAuthGuard.ts new file mode 100644 index 00000000..f08a5acc --- /dev/null +++ b/frontend/src/hooks/useAuthGuard.ts @@ -0,0 +1,171 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { auth } from '@/config/firebase'; +import { UserRole } from '@/types/authTypes'; +import baseAPIClient from '@/APIClients/baseAPIClient'; + +interface AxiosError { + response?: { + status: number; + data: unknown; + }; + request?: unknown; + message?: string; +} + +interface AuthGuardState { + loading: boolean; + authorized: boolean; +} + +// Map role IDs to UserRole enum +const roleIdToUserRole = (roleId: number): UserRole | null => { + switch (roleId) { + case 1: + return UserRole.PARTICIPANT; + case 2: + return UserRole.VOLUNTEER; + case 3: + return UserRole.ADMIN; + default: + return null; + } +}; + +/** + * Hook to protect pages with authentication and role-based access control + * @param allowedRoles - Array of roles that can access this page + * @returns Object with loading and authorized states + */ +export const useAuthGuard = (allowedRoles: UserRole[]): AuthGuardState => { + const router = useRouter(); + const [authState, setAuthState] = useState({ + loading: true, + authorized: false, + }); + + // Memoize allowedRoles to prevent infinite re-renders + const memoizedAllowedRoles = useMemo(() => allowedRoles, [allowedRoles]); + + const getUserRole = async (user: User): Promise => { + const cacheKey = `userRole_${user.uid}`; + + // Check cache first + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const { role, timestamp } = JSON.parse(cached); + // Cache valid for 1 hour + if (Date.now() - timestamp < 3600000) { + return role; + } + // Remove expired cache + sessionStorage.removeItem(cacheKey); + } + } catch { + // If cache is corrupted, remove it and continue + sessionStorage.removeItem(cacheKey); + } + + try { + // Get the Firebase ID token + const token = await user.getIdToken(); + // Call your backend to get user data with role + const response = await baseAPIClient.get('/auth/me', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // Convert roleId to UserRole enum (API client converts snake_case to camelCase) + const userRole = roleIdToUserRole(response.data.roleId); + + // Cache the result + if (userRole) { + sessionStorage.setItem( + cacheKey, + JSON.stringify({ + role: userRole, + timestamp: Date.now(), + }), + ); + } + + return userRole; + } catch (error: unknown) { + console.error('[useAuthGuard] Error fetching user role:', error); + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as AxiosError; + console.error('[useAuthGuard] API Error status:', axiosError.response?.status); + console.error('[useAuthGuard] API Error data:', axiosError.response?.data); + } else if (error && typeof error === 'object' && 'request' in error) { + console.error('[useAuthGuard] No response received:', (error as AxiosError).request); + } else if (error && typeof error === 'object' && 'message' in error) { + console.error('[useAuthGuard] Request setup error:', (error as AxiosError).message); + } + console.error('[useAuthGuard] Full error object:', error); + return null; + } + }; + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (user) => { + try { + if (!user) { + // No authenticated user - redirect to login + router.push('/'); + return; + } + + // Check if email is verified + if (!user.emailVerified) { + router.push(`/verify?email=${encodeURIComponent(user.email || '')}`); + return; + } + + // Get user role from backend + const userRole = await getUserRole(user); + + if (!userRole) { + // Could not get user role - redirect to login + router.push('/'); + return; + } + + if (!memoizedAllowedRoles.includes(userRole)) { + // User doesn't have required role - redirect to unauthorized + router.push('/unauthorized'); + return; + } + + // User is authorized + setAuthState({ loading: false, authorized: true }); + } catch (error) { + console.error('Auth guard error:', error); + router.push('/'); + } + }); + + return () => unsubscribe(); + }, [router, memoizedAllowedRoles]); + + return authState; +}; + +/** + * Clear all cached user role data from session storage + * Call this function when the user logs out + */ +export const clearAuthCache = (): void => { + try { + Object.keys(sessionStorage).forEach((key) => { + if (key.startsWith('userRole_')) { + sessionStorage.removeItem(key); + } + }); + } catch (error) { + // Session storage might not be available (e.g., in SSR or private browsing) + console.warn('[useAuthGuard] Could not clear auth cache:', error); + } +}; diff --git a/frontend/src/hooks/useProtectedRoute.ts b/frontend/src/hooks/useProtectedRoute.ts new file mode 100644 index 00000000..ff60d6bc --- /dev/null +++ b/frontend/src/hooks/useProtectedRoute.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import { UserRole } from '@/types/authTypes'; +import { AuthLoadingSkeleton } from '@/components/auth/AuthLoadingSkeleton'; +import { useAuthGuard } from './useAuthGuard'; + +interface UseProtectedRouteResult { + loading: boolean; + authorized: boolean; + LoadingComponent: React.ComponentType | null; +} + +/** + * Hook that combines auth guarding with loading skeleton + * Returns a LoadingComponent that you can render while auth is being checked + * + * @param allowedRoles - Array of roles that can access this page + * @returns Object with loading state, authorized state, and LoadingComponent + */ +export const useProtectedRoute = (allowedRoles: UserRole[]): UseProtectedRouteResult => { + const { loading, authorized } = useAuthGuard(allowedRoles); + + const LoadingComponent = loading ? () => React.createElement(AuthLoadingSkeleton) : null; + + return { + loading, + authorized, + LoadingComponent, + }; +}; diff --git a/frontend/src/pages/admin/dashboard.tsx b/frontend/src/pages/admin/dashboard.tsx index 8b78b6e0..3af94c2b 100644 --- a/frontend/src/pages/admin/dashboard.tsx +++ b/frontend/src/pages/admin/dashboard.tsx @@ -1,86 +1,90 @@ import React from 'react'; import Image from 'next/image'; import { Box, Flex, Heading, Text, Link } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; const veniceBlue = '#1d3448'; export default function AdminDashboard() { return ( - - {/* Left: Dashboard Content */} - - - - Admin Portal - First Connection Peer Support Program - - - Welcome! - - - We sent a confirmation link to john.doe@gmail.com - - - Didn't get a link?{' '} - + + {/* Left: Dashboard Content */} + + + - Click here to resend. - - + Admin Portal - First Connection Peer Support Program + + + Welcome! + + + We sent a confirmation link to john.doe@gmail.com + + + Didn't get a link?{' '} + + Click here to resend. + + + + + {/* Right: Image */} + + Admin Portal Visual - {/* Right: Image */} - - Admin Portal Visual - - + ); } diff --git a/frontend/src/pages/participant/intake.tsx b/frontend/src/pages/participant/intake.tsx index 0bff1d50..78f7a897 100644 --- a/frontend/src/pages/participant/intake.tsx +++ b/frontend/src/pages/participant/intake.tsx @@ -15,6 +15,8 @@ import { ExperienceData, PersonalData, } from '@/constants/form'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; // Import the component data types interface DemographicCancerFormData { @@ -194,39 +196,45 @@ export default function ParticipantIntakePage() { // If we're on thank you step, show the screen with form data if (currentStepType === 'thank-you') { - return ; + return ( + + + + ); } return ( - - - {currentStepType === 'experience-personal' && ( - - )} - - {currentStepType === 'demographics-cancer' && ( - - )} - - {currentStepType === 'demographics-caregiver' && ( - - )} - - {currentStepType === 'loved-one' && ( - - )} - - {currentStepType === 'demographics-basic' && ( - - )} - - + + + + {currentStepType === 'experience-personal' && ( + + )} + + {currentStepType === 'demographics-cancer' && ( + + )} + + {currentStepType === 'demographics-caregiver' && ( + + )} + + {currentStepType === 'loved-one' && ( + + )} + + {currentStepType === 'demographics-basic' && ( + + )} + + + ); } diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx new file mode 100644 index 00000000..d8893c87 --- /dev/null +++ b/frontend/src/pages/participant/ranking.tsx @@ -0,0 +1,586 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { UserIcon, CheckMarkIcon, WelcomeScreen } from '@/components/ui'; +import { + VolunteerMatchingForm, + VolunteerRankingForm, + CaregiverMatchingForm, + CaregiverQualitiesForm, + CaregiverRankingForm, + CaregiverTwoColumnQualitiesForm, +} from '@/components/ranking'; +import { COLORS } from '@/constants/form'; +import baseAPIClient from '@/APIClients/baseAPIClient'; +import { auth } from '@/config/firebase'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; + +const RANKING_STATEMENTS = [ + 'I would prefer a volunteer with the same age as me', + 'I would prefer a volunteer with the same diagnosis as me', + 'I would prefer a volunteer with the same marital status as me', + 'I would prefer a volunteer with the same ethnic or cultural group as me', + 'I would prefer a volunteer with the same parental status as me', +]; + +const CAREGIVER_RANKING_STATEMENTS = [ + 'I would prefer a volunteer with the same age as my loved one', + 'I would prefer a volunteer with the same diagnosis as my loved one', + 'I would prefer a volunteer with experience with Relapse', + 'I would prefer a volunteer with experience with Anxiety / Depression', + 'I would prefer a volunteer with experience with returning to school or work during/after treatment', +]; + +interface RankingFormData { + selectedQualities: string[]; // option keys + rankedPreferences: string[]; // labels to display + rankedKeys: string[]; // option keys in rank order + volunteerType?: string; + isCaregiverVolunteerFlow?: boolean; +} + +interface ParticipantRankingPageProps { + participantType?: 'cancerPatient' | 'caregiver'; + caregiverHasCancer?: boolean; +} + +type Scope = 'self' | 'loved_one'; + +// NOTE: Responses are camelCased by our Axios interceptor +type StaticQuality = { qualityId: number; slug: string; label: string; allowedScopes?: Scope[] }; + +type DynamicOption = { kind: 'treatment' | 'experience'; id: number; name: string; scope: Scope }; + +type OptionsResponse = { staticQualities: StaticQuality[]; dynamicOptions: DynamicOption[] }; + +type DisplayOption = { + key: string; + label: string; + meta: { kind: 'quality' | 'treatment' | 'experience'; id: number; scope: Scope }; +}; + +export default function ParticipantRankingPage({ + participantType = 'caregiver', + caregiverHasCancer = true, +}: ParticipantRankingPageProps) { + const [derivedParticipantType, setDerivedParticipantType] = useState< + 'cancerPatient' | 'caregiver' | null + >(null); + const [derivedCaregiverHasCancer, setDerivedCaregiverHasCancer] = useState(null); + const [isLoadingCase, setIsLoadingCase] = useState(false); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + const run = async () => { + try { + setIsLoadingCase(true); + const { data } = await baseAPIClient.get('/ranking/case'); + if (data && data.case) { + if (data.case === 'patient') { + setDerivedParticipantType('cancerPatient'); + setDerivedCaregiverHasCancer(null); + } else if (data.case === 'caregiver_with_cancer') { + setDerivedParticipantType('caregiver'); + setDerivedCaregiverHasCancer(true); + } else if (data.case === 'caregiver_without_cancer') { + setDerivedParticipantType('caregiver'); + setDerivedCaregiverHasCancer(false); + } + } + } catch { + // Non-blocking for now + } finally { + setIsLoadingCase(false); + } + }; + if (auth.currentUser) { + run(); + } else { + unsubscribe = auth.onIdTokenChanged((user) => { + if (user) { + run(); + if (unsubscribe) unsubscribe(); + } + }); + } + return () => { + if (unsubscribe) unsubscribe(); + }; + }, []); + + useEffect(() => { + if (derivedParticipantType !== null || derivedCaregiverHasCancer !== null) { + console.log('[RANKING_CASE]', { + participantType: derivedParticipantType, + caregiverHasCancer: derivedCaregiverHasCancer, + isLoadingCase, + }); + } + }, [derivedParticipantType, derivedCaregiverHasCancer, isLoadingCase]); + + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const [optionsError, setOptionsError] = useState(null); + const [singleColumnOptions, setSingleColumnOptions] = useState([]); + const [leftColumnOptions, setLeftColumnOptions] = useState([]); + const [rightColumnOptions, setRightColumnOptions] = useState([]); + + // Helper to index options by key for quick lookups + const optionsIndex = useMemo(() => { + const index: Record = {}; + [...singleColumnOptions, ...leftColumnOptions, ...rightColumnOptions].forEach((opt) => { + index[opt.key] = opt; + }); + return index; + }, [singleColumnOptions, leftColumnOptions, rightColumnOptions]); + + const fetchOptions = async (target: 'patient' | 'caregiver') => { + try { + setIsLoadingOptions(true); + setOptionsError(null); + const { data } = await baseAPIClient.get('/ranking/options', { + params: { target }, + }); + const staticQualitiesExpanded: DisplayOption[] = (data.staticQualities || []).flatMap((q) => { + const scopes = q.allowedScopes || []; + return scopes.map((s) => ({ + key: `quality:${q.qualityId}:${s}`, + label: `${q.label} ${s === 'self' ? 'me' : 'my loved one'}`, + meta: { kind: 'quality', id: q.qualityId, scope: s }, + })); + }); + const dynamicOptions: DisplayOption[] = (data.dynamicOptions || []).map((o) => ({ + key: `${o.kind}:${o.id}:${o.scope}`, + label: `experience with ${o.name}`, + meta: { kind: o.kind, id: o.id, scope: o.scope }, + })); + + const combinedSingle = [...staticQualitiesExpanded, ...dynamicOptions]; + setSingleColumnOptions(combinedSingle); + + const left: DisplayOption[] = []; + const right: DisplayOption[] = []; + staticQualitiesExpanded.forEach((opt) => { + if (opt.meta.scope === 'self') left.push(opt); + if (opt.meta.scope === 'loved_one') right.push(opt); + }); + dynamicOptions.forEach((opt) => { + if (opt.meta.scope === 'self') left.push(opt); + if (opt.meta.scope === 'loved_one') right.push(opt); + }); + setLeftColumnOptions(left); + setRightColumnOptions(right); + } catch { + setOptionsError('Failed to load options. Please try again.'); + } finally { + setIsLoadingOptions(false); + } + }; + + // Prefer derived case values from backend when available + const effectiveParticipantType: 'cancerPatient' | 'caregiver' = + (derivedParticipantType as 'cancerPatient' | 'caregiver') ?? participantType; + const effectiveCaregiverHasCancer: boolean = + derivedParticipantType === 'caregiver' + ? (derivedCaregiverHasCancer ?? caregiverHasCancer) + : caregiverHasCancer; + + const [currentStep, setCurrentStep] = useState(1); + const [formData, setFormData] = useState({ + selectedQualities: [], + rankedPreferences: + participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], + rankedKeys: [], + volunteerType: participantType === 'caregiver' ? '' : undefined, + isCaregiverVolunteerFlow: undefined, + }); + + const WelcomeScreenStep = () => ( + } + title="Welcome to the Peer Support Program!" + description="Let's begin by selecting
your preferences in a volunteer." + onContinue={() => setCurrentStep(2)} + /> + ); + + const QualitiesScreen = () => { + const toggleQuality = (key: string) => { + setFormData((prev) => ({ + ...prev, + selectedQualities: prev.selectedQualities.includes(key) + ? prev.selectedQualities.filter((q) => q !== key) + : prev.selectedQualities.length < 5 + ? [...prev.selectedQualities, key] + : prev.selectedQualities, + })); + }; + + const handleVolunteerTypeChange = (type: string) => { + setFormData((prev) => ({ + ...prev, + volunteerType: type, + isCaregiverVolunteerFlow: type === 'caringForLovedOne', + })); + }; + + // For patient flow, fetch options once + useEffect(() => { + if ( + effectiveParticipantType === 'cancerPatient' && + singleColumnOptions.length === 0 && + !isLoadingOptions + ) { + fetchOptions('patient'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveParticipantType]); + + return ( + + + {optionsError ? ( + + {optionsError} + + ) : null} + + {effectiveParticipantType === 'caregiver' ? ( + { + setFormData((prev) => ({ + ...prev, + volunteerType: type, + isCaregiverVolunteerFlow: type === 'caringForLovedOne', + })); + // Prefetch options based on caregiver choice before advancing + const target: 'patient' | 'caregiver' = + type === 'caringForLovedOne' ? 'caregiver' : 'patient'; + try { + await fetchOptions(target); + } catch {} + setCurrentStep(3); + }} + /> + ) : ( + { + // Build ranking arrays from selected keys + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(3); + }} + /> + )} + + + ); + }; + + const CaregiverQualitiesScreen = () => { + const toggleQuality = (key: string) => { + setFormData((prev) => ({ + ...prev, + selectedQualities: prev.selectedQualities.includes(key) + ? prev.selectedQualities.filter((q) => q !== key) + : prev.selectedQualities.length < 5 + ? [...prev.selectedQualities, key] + : prev.selectedQualities, + })); + }; + + return ( + + + { + // Prefer explicit flag; otherwise infer from value + (formData.isCaregiverVolunteerFlow ?? false) || + formData.volunteerType === 'caringForLovedOne' || + (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') ? ( + { + if ( + leftColumnOptions.length === 0 && + rightColumnOptions.length === 0 && + !isLoadingOptions + ) { + fetchOptions('caregiver'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) : effectiveCaregiverHasCancer ? ( + formData.volunteerType === 'similarDiagnosis' ? ( + { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) : ( + { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) + ) : ( + { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) + } + + + ); + }; + + const RankingScreen = () => { + const moveItem = (fromIndex: number, toIndex: number) => { + setFormData((prev) => { + const newLabels = [...prev.rankedPreferences]; + const newKeys = [...prev.rankedKeys]; + const [movedLabel] = newLabels.splice(fromIndex, 1); + const [movedKey] = newKeys.splice(fromIndex, 1); + newLabels.splice(toIndex, 0, movedLabel); + newKeys.splice(toIndex, 0, movedKey); + return { ...prev, rankedPreferences: newLabels, rankedKeys: newKeys }; + }); + }; + + const nextStep = effectiveParticipantType === 'caregiver' ? 5 : 4; + + const handleSubmit = async () => { + // Determine target based on flow + let target: 'patient' | 'caregiver' = 'patient'; + if ( + effectiveParticipantType === 'caregiver' && + ((formData.isCaregiverVolunteerFlow ?? false) || + formData.volunteerType === 'caringForLovedOne') + ) { + target = 'caregiver'; + } else { + target = 'patient'; + } + const items = formData.rankedKeys.map((key, idx) => { + const opt = optionsIndex[key]; + return { + kind: opt?.meta.kind === 'quality' ? 'quality' : opt?.meta.kind, + id: opt?.meta.id, + scope: opt?.meta.scope, + rank: idx + 1, + }; + }); + try { + await baseAPIClient.put('/ranking/preferences', items, { params: { target } }); + setCurrentStep(nextStep); + } catch (e) { + console.error('Failed to save preferences', e); + } + }; + + return ( + + + {effectiveParticipantType === 'caregiver' ? ( + optionsIndex[k]?.meta.scope || 'self')} + itemKinds={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.kind || 'quality')} + /> + ) : ( + optionsIndex[k]?.meta.scope || 'self')} + itemKinds={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.kind || 'quality')} + /> + )} + + + ); + }; + + const ThankYouScreen = () => ( + + + + + + + Thank you for sharing your experience and + + + preferences with us. + + + + We are reviewing which volunteers would best fit those preferences. You will receive an + email from us in the next 1-2 business days with the next steps. If you would like to + connect with a LLSC staff before then, please reach out to{' '} + + FirstConnections@lls.org + + . + + + + + ); + + return ( + + {participantType === 'caregiver' + ? (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + default: + return ; + } + })() + : (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + default: + return ; + } + })()} + + ); +} diff --git a/frontend/src/pages/unauthorized.tsx b/frontend/src/pages/unauthorized.tsx new file mode 100644 index 00000000..5143749a --- /dev/null +++ b/frontend/src/pages/unauthorized.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; + +const veniceBlue = '#1d3448'; +const teal = '#056067'; + +export default function Unauthorized() { + return ( + + + + 403 + + + + Access Denied + + + + You don't have permission to access this page. +
+ Please contact your administrator if you believe this is an error. +
+ + + + +
+
+ ); +} diff --git a/frontend/src/pages/volunteer/intake.tsx b/frontend/src/pages/volunteer/intake.tsx index 5143bfa5..d5750624 100644 --- a/frontend/src/pages/volunteer/intake.tsx +++ b/frontend/src/pages/volunteer/intake.tsx @@ -15,6 +15,8 @@ import { ExperienceData, PersonalData, } from '@/constants/form'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; // Import the component data types interface DemographicCancerFormData { @@ -94,19 +96,14 @@ export default function VolunteerIntakePage() { if (nextType === 'thank-you') { setSubmitting(true); try { - // eslint-disable-next-line no-console - console.log('[INTAKE][SUBMIT] About to submit answers (volunteer)', { - currentStep, - nextType, - answers: updated, - }); await baseAPIClient.post('/intake/submissions', { answers: updated }); - } catch (error: any) { + } catch (error: unknown) { // eslint-disable-next-line no-console - console.error( - '[INTAKE][SUBMIT][ERROR] Volunteer submission failed', - error?.response?.data || error, - ); + const errorData = + error && typeof error === 'object' && 'response' in error + ? (error as { response?: { data?: unknown } })?.response?.data || error + : error; + console.error('[INTAKE][SUBMIT][ERROR] Volunteer submission failed', errorData); return; // Do not advance on failure } finally { setSubmitting(false); @@ -208,39 +205,45 @@ export default function VolunteerIntakePage() { // If we're on thank you step, show the screen with form data if (currentStepType === 'thank-you') { - return ; + return ( + + + + ); } return ( - - - {currentStepType === 'experience-personal' && ( - - )} - - {currentStepType === 'demographics-cancer' && ( - - )} - - {currentStepType === 'demographics-caregiver' && ( - - )} - - {currentStepType === 'loved-one' && ( - - )} - - {currentStepType === 'demographics-basic' && ( - - )} - - + + + + {currentStepType === 'experience-personal' && ( + + )} + + {currentStepType === 'demographics-cancer' && ( + + )} + + {currentStepType === 'demographics-caregiver' && ( + + )} + + {currentStepType === 'loved-one' && ( + + )} + + {currentStepType === 'demographics-basic' && ( + + )} + + + ); }