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 */}
+
+
- {/* Right: Image */}
-
-
-
-
+
);
}
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' && (
+
+ )}
+
+
+
);
}