diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7d61f96b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The repository separates API and UI concerns. `backend/app` houses the FastAPI stack—middlewares, models, services, routes, schemas, and utilities—with migrations in `backend/migrations`. Automated tests live in `backend/tests` (unit and functional) plus `e2e-tests` for request-driven smoke checks. The Next.js client resides in `frontend/src`, where `components`, `pages`, `contexts`, `APIClients`, and `utils` cover the feature surface. + +## Build, Test, and Development Commands +- `docker-compose up -d` boots Postgres and supporting services; pair with `docker-compose down -v` to rebuild from scratch. +- `cd backend && pdm install` prepares Python deps; `pdm run dev` starts FastAPI on `http://localhost:8080`. +- `cd frontend && npm install` sets up the UI; `npm run dev` runs the Next.js app on `http://localhost:3000`. +- `pdm run tests` executes the backend pytest suite; `npm run lint` runs ESLint/TypeScript checks; `npm run build` verifies a production bundle. + +## Coding Style & Naming Conventions +Backend code targets Python 3.12 with Ruff enforcing 120-character lines, import sorting, and lint rules; run `pdm run ruff format` before committing. Use snake_case for files, modules, and functions, PascalCase for SQLAlchemy models and Pydantic schemas, and keep service interfaces under `services/interfaces`. Frontend TypeScript follows Prettier defaults (2-space indentation) and Next.js conventions: PascalCase React components, camelCase hooks, and colocated styles in `src/styles` when needed. + +## Testing Guidelines +Pytest expects files named `test_*.py` in `backend/tests`, with async fixtures available for FastAPI routes. Focus unit tests on service logic and place HTTP flow checks in `tests/functional`. Gather coverage with `pytest --cov=app` when shipping high-risk changes. The `e2e-tests` directory hosts request-based regression scripts; run them against a live stack (`BACKEND_URL` set) before deploying auth or entity flows. Frontend currently relies on linting and type checks—add component tests under `frontend/src/__tests__` when introducing complex interactions. + +## Commit & Pull Request Guidelines +Commits should be atomic and written in imperative mood (e.g., “Add intake confirmation flow”), mirroring existing history. Reference tickets in the body when relevant. Pull requests must summarize scope, note migrations or env updates, and include screenshots or GIFs for UI changes. Confirm linting, formatting, and tests ran successfully, and call out follow-up items so reviewers can assess risk quickly. + +## Security & Configuration Tips +Never commit secrets. Copy `.env.sample` into `backend/.env` and `frontend/.env`, sourcing values from the LLSC vault. Keep `backend/serviceAccountKey.json` local; it is already gitignored. When adding loggers, register names via `app/utilities/constants.py` to preserve structured logging across services. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6517e617 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,180 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LLSC (Leukemia & Lymphoma Society of Canada) is a full-stack application built for matching participants with volunteers. The application uses a monorepo structure with separate frontend and backend services. + +**Tech Stack:** +- Backend: FastAPI (Python 3.12), SQLAlchemy, PostgreSQL, Alembic migrations +- Frontend: Next.js, React, TypeScript, Chakra UI +- Infrastructure: Docker, Docker Compose +- Auth: Firebase Authentication +- Email: AWS SES + +## Development Commands + +### Initial Setup +```bash +# Start Docker containers (required for database) +docker-compose up -d + +# Backend setup +cd backend +pdm install +pdm run dev # Runs on http://localhost:8000 + +# Frontend setup +cd frontend +npm install +npm run dev # Runs on http://localhost:3000 +``` + +### Backend Commands +```bash +cd backend + +# Development +pdm run dev # Start FastAPI dev server (port 8000) + +# Database +pdm run alembic revision --autogenerate -m "message" # Create migration +pdm run alembic upgrade head # Apply migrations +pdm run seed # Seed database with reference data +pdm run db-reset # Reset DB: down, up, migrate, seed + +# Testing +pdm run tests # Run pytest tests + +# Linting & Formatting +pdm run ruff check . # Check linting +pdm run ruff check --fix . # Fix linting issues +pdm run ruff format . # Format code +pdm run precommit # Run pre-commit hooks manually +``` + +### Frontend Commands +```bash +cd frontend + +npm run dev # Start Next.js dev server +npm run build # Build for production +npm run lint # Run ESLint +npm run lint:fix # Fix ESLint issues +npm run prettier:check # Check formatting +npm run prettier:fix # Fix formatting +npm run format # Run both prettier and eslint fixes +``` + +### Docker Commands +```bash +# Database access +docker exec -it llsc_db psql -U postgres -d llsc + +# Container management +docker-compose up --build # Rebuild and start +docker-compose down # Stop containers +docker-compose down --volumes # Stop and remove volumes +docker ps # List running containers +``` + +## Architecture + +### Backend Structure + +The backend follows a **layered service architecture**: + +- **Models** (`app/models/`): SQLAlchemy ORM models. All models must be imported in `app/models/__init__.py` for Alembic autogeneration to work. +- **Schemas** (`app/schemas/`): Pydantic schemas for request/response validation. +- **Routes** (`app/routes/`): FastAPI route handlers that accept requests and return responses. +- **Services** (`app/services/`): Business logic layer that routes call into. + - `implementations/`: Concrete service implementations +- **Interfaces** (`app/interfaces/`): Abstract base classes defining service contracts. +- **Middleware** (`app/middleware/`): Request/response middleware (e.g., `AuthMiddleware`). +- **Utilities** (`app/utilities/`): Shared utilities, constants, Firebase, SES initialization. +- **Seeds** (`app/seeds/`): Database seeding scripts for reference data. + +**Key Backend Files:** +- `app/server.py`: FastAPI application initialization, middleware setup, route registration +- `app/__init__.py`: Contains `run_migrations()` which auto-runs Alembic migrations on startup +- `app/utilities/constants.py`: Contains `LOGGER_NAME()` function for creating standardized loggers + +**Authentication Flow:** +- Firebase tokens are validated via `AuthMiddleware` +- Public paths are defined in `server.py` (`PUBLIC_PATHS`) +- Protected routes require valid Firebase auth tokens + +**Database Migrations:** +When adding a new model, you MUST: +1. Create the model file in `app/models/` +2. Import it in `app/models/__init__.py` and add to `__all__` +3. Run `pdm run alembic revision --autogenerate -m "description"` +4. Run `pdm run alembic upgrade head` + +### Frontend Structure + +The frontend is a **Next.js application** using TypeScript and Chakra UI: + +- **Pages** (`src/pages/`): Next.js file-based routing + - `participant/`: Participant-specific pages (intake, ranking) + - `volunteer/`: Volunteer-specific pages (intake, secondary application) + - `admin/`: Admin dashboard pages +- **Components** (`src/components/`): Reusable React components + - `auth/`: Authentication-related components + - `intake/`: Form intake components + - `ranking/`: Ranking interface components + - `ui/`: Base UI components +- **API Clients** (`src/APIClients/`): API communication layer with backend +- **Contexts** (`src/contexts/`): React Context providers for global state +- **Hooks** (`src/hooks/`): Custom React hooks +- **Types** (`src/types/`): TypeScript type definitions +- **Utils** (`src/utils/`): Utility functions + +**Key Frontend Files:** +- `src/pages/_app.tsx`: Next.js app wrapper, providers setup +- `src/pages/_document.tsx`: Custom document for Next.js + +## Version Control + +### Branching +- Branch off `main` for all feature work +- Branch naming: `/-description` (e.g., `mslwang/LLSC-42-readme-update`) +- Use rebase instead of merge to integrate main: `git pull origin main --rebase` + +### Commits +- Commits should be atomic and self-contained +- Use imperative tense with capitalized first word (e.g., "Add user authentication") +- Squash trivial commits (typos, formatting) using `git rebase -i` + +## Logging + +To add a logger to any file: +```python +from app.utilities.constants import LOGGER_NAME +import logging + +log = logging.getLogger(LOGGER_NAME("my_service")) +``` + +New logger names must be added to `alembic.ini` under the logger section. + +## Testing + +- Backend tests: `pdm run tests` (runs pytest in `backend/tests/`) +- Test structure: `tests/unit/` and `tests/functional/` +- CI runs linting, formatting checks, unit tests, and security scans on every PR + +## Firebase Configuration + +Backend requires: +1. `serviceAccountKey.json` in `backend/` directory (obtain from Firebase Console) +2. `FIREBASE_WEB_API_KEY` in `.env` file + +## Environment Variables + +Environment variables are stored in: +- Backend: `.env` in project root (loaded by backend service) +- Frontend: `frontend/.env` + +Secrets are documented in the LLSC Notion workspace. diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 31c62cc4..373f5f1c 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -1,6 +1,8 @@ import uuid +from enum import Enum as PyEnum from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +from sqlalchemy import Enum as SQLEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -8,6 +10,16 @@ from .Match import Match +class FormStatus(str, PyEnum): + INTAKE_TODO = "intake-todo" + INTAKE_SUBMITTED = "intake-submitted" + RANKING_TODO = "ranking-todo" + RANKING_SUBMITTED = "ranking-submitted" + SECONDARY_APPLICATION_TODO = "secondary-application-todo" + SECONDARY_APPLICATION_SUBMITTED = "secondary-application-submitted" + COMPLETED = "completed" + + class User(Base): __tablename__ = "users" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -18,6 +30,16 @@ class User(Base): auth_id = Column(String, nullable=False) approved = Column(Boolean, default=False) active = Column(Boolean, nullable=False, default=True) + form_status = Column( + SQLEnum( + FormStatus, + name="form_status_enum", + create_type=False, + values_callable=lambda enum_cls: [member.value for member in enum_cls], + ), + nullable=False, + default=FormStatus.INTAKE_TODO, + ) role = relationship("Role") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4b9fdf94..9d62a259 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,7 +21,7 @@ from .SuggestedTime import suggested_times from .TimeBlock import TimeBlock from .Treatment import Treatment -from .User import User +from .User import FormStatus, User from .UserData import UserData # Used to avoid import errors for the models @@ -42,6 +42,7 @@ "RankingPreference", "Form", "FormSubmission", + "FormStatus", ] log = logging.getLogger(LOGGER_NAME("models")) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 1fb1d859..e2abd2c9 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -127,6 +127,7 @@ async def get_current_user( role_id=user.role_id, auth_id=user.auth_id, approved=user.approved, + form_status=user.form_status, ) except HTTPException: raise diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index e8040ce5..0508ab57 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -35,6 +35,16 @@ def to_role_id(cls, role: "UserRole") -> int: return role_map[role] +class FormStatus(str, Enum): + INTAKE_TODO = "intake-todo" + INTAKE_SUBMITTED = "intake-submitted" + RANKING_TODO = "ranking-todo" + RANKING_SUBMITTED = "ranking-submitted" + SECONDARY_APPLICATION_TODO = "secondary-application-todo" + SECONDARY_APPLICATION_SUBMITTED = "secondary-application-submitted" + COMPLETED = "completed" + + class UserBase(BaseModel): """ Base schema for user model with common attributes shared across schemas. @@ -91,6 +101,7 @@ class UserUpdateRequest(BaseModel): email: Optional[EmailStr] = None role: Optional[UserRole] = None approved: Optional[bool] = None + form_status: Optional[FormStatus] = None class UserCreateResponse(BaseModel): @@ -105,6 +116,7 @@ class UserCreateResponse(BaseModel): role_id: int auth_id: str approved: bool + form_status: FormStatus # from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model model_config = ConfigDict(from_attributes=True) @@ -123,6 +135,7 @@ class UserResponse(BaseModel): auth_id: str approved: bool role: "RoleResponse" + form_status: FormStatus model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 59cc08f9..80e1be00 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -7,7 +7,7 @@ from app.models.Experience import Experience from app.models.Treatment import Treatment -from app.models.User import User +from app.models.User import FormStatus, User from app.models.UserData import UserData from app.utilities.form_constants import ExperienceId, TreatmentId @@ -316,6 +316,7 @@ def seed_users(session: Session) -> None: auth_id=user_info["user_data"]["auth_id"], approved=True, active=True, + form_status=FormStatus.INTAKE_TODO, ) session.add(user) session.flush() # Get user ID diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 13fb57fe..5d32f404 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session -from app.models import Experience, Treatment, User, UserData +from app.models import Experience, FormStatus, Treatment, User, UserData logger = logging.getLogger(__name__) @@ -72,6 +72,14 @@ def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> Us if owning_user and owning_user.email: user_data.email = owning_user.email + # Update form status for the owning user without regressing progress + owning_user = self.db.query(User).filter(User.id == user_data.user_id).first() + if owning_user and owning_user.form_status in { + FormStatus.INTAKE_TODO, + FormStatus.INTAKE_SUBMITTED, + }: + owning_user.form_status = FormStatus.INTAKE_SUBMITTED + # Commit all changes self.db.commit() self.db.refresh(user_data) diff --git a/backend/app/services/implementations/ranking_service.py b/backend/app/services/implementations/ranking_service.py index ccb076b8..583ae222 100644 --- a/backend/app/services/implementations/ranking_service.py +++ b/backend/app/services/implementations/ranking_service.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session -from app.models import Quality, User, UserData +from app.models import FormStatus, Quality, User, UserData from app.models.RankingPreference import RankingPreference @@ -205,4 +205,11 @@ def save_preferences(self, user_auth_id: str, target: str, items: List[Dict]) -> ) if normalized: self.db.bulk_save_objects(normalized) + + if user.form_status in ( + FormStatus.RANKING_TODO, + FormStatus.RANKING_SUBMITTED, + ): + user.form_status = FormStatus.RANKING_SUBMITTED + self.db.commit() diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 610eabb9..995a073a 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from app.interfaces.user_service import IUserService -from app.models import Role, User +from app.models import FormStatus, Role, User from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -38,6 +38,10 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: role_id = UserRole.to_role_id(user.role) + initial_status = FormStatus.INTAKE_TODO + if role_id == UserRole.to_role_id(UserRole.ADMIN): + initial_status = FormStatus.COMPLETED + # Create user in database db_user = User( first_name=user.first_name or "", @@ -45,6 +49,7 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: email=user.email, role_id=role_id, auth_id=firebase_user.uid, + form_status=initial_status, ) self.db.add(db_user) @@ -203,6 +208,12 @@ async def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) if "role" in update_data: update_data["role_id"] = UserRole.to_role_id(update_data.pop("role")) + if "form_status" in update_data: + try: + update_data["form_status"] = FormStatus(update_data["form_status"]) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid form status") + for field, value in update_data.items(): setattr(db_user, field, value) diff --git a/backend/migrations/versions/b56e0bf600a2_add_form_status_to_users.py b/backend/migrations/versions/b56e0bf600a2_add_form_status_to_users.py new file mode 100644 index 00000000..d7c7a51d --- /dev/null +++ b/backend/migrations/versions/b56e0bf600a2_add_form_status_to_users.py @@ -0,0 +1,55 @@ +"""add form status to users + +Revision ID: b56e0bf600a2 +Revises: a59aeb0bd691 +Create Date: 2025-02-15 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b56e0bf600a2" +down_revision: Union[str, None] = "a59aeb0bd691" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_FORM_STATUS_VALUES = ( + "intake-todo", + "intake-submitted", + "ranking-todo", + "ranking-submitted", + "secondary-application-todo", + "secondary-application-submitted", + "completed", +) + +_DEFAULT_STATUS = "intake-todo" +_ADMIN_STATUS = "completed" + + +def upgrade() -> None: + op.execute( + "CREATE TYPE form_status_enum AS ENUM ('intake-todo', 'intake-submitted', 'ranking-todo', " + "'ranking-submitted', 'secondary-application-todo', 'secondary-application-submitted', 'completed')" + ) + + op.add_column( + "users", + sa.Column( + "form_status", + sa.Enum(*_FORM_STATUS_VALUES, name="form_status_enum", create_type=False), + nullable=False, + server_default=_DEFAULT_STATUS, + ), + ) + + op.execute(f"UPDATE users SET form_status = '{_ADMIN_STATUS}' WHERE role_id = 3") + + +def downgrade() -> None: + op.drop_column("users", "form_status") + op.execute("DROP TYPE form_status_enum") diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index eda98104..47f6a71e 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -8,7 +8,7 @@ from app.models.Experience import Experience from app.models.Role import Role from app.models.Treatment import Treatment -from app.models.User import User +from app.models.User import FormStatus, User from app.models.UserData import UserData from app.schemas.user import UserRole from app.services.implementations.intake_form_processor import IntakeFormProcessor @@ -214,6 +214,9 @@ def test_participant_with_cancer_only(db_session, test_user): assert len(user_data.loved_one_treatments) == 0 assert len(user_data.loved_one_experiences) == 0 + db_session.refresh(test_user) + assert test_user.form_status == FormStatus.INTAKE_SUBMITTED + db_session.commit() except Exception: @@ -302,6 +305,9 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): assert "Depression" in loved_one_experience_names assert "Fatigue" in loved_one_experience_names + db_session.refresh(test_user) + assert test_user.form_status == FormStatus.INTAKE_SUBMITTED + db_session.commit() except Exception: @@ -374,6 +380,9 @@ def test_form_submission_json_structure(db_session, test_user): assert len(user_data.loved_one_treatments) >= 2 # Radiation + Palliative assert len(user_data.loved_one_experiences) >= 2 # Brain Fog + Feeling Overwhelmed + db_session.refresh(test_user) + assert test_user.form_status == FormStatus.INTAKE_SUBMITTED + db_session.commit() except Exception: @@ -430,6 +439,9 @@ def test_empty_and_minimal_data_handling(db_session, test_user): assert len(user_data.experiences) == 0 assert user_data.loved_one_gender_identity is None + db_session.refresh(test_user) + assert test_user.form_status == FormStatus.INTAKE_SUBMITTED + db_session.commit() except Exception: diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py index 56529055..c6976955 100644 --- a/backend/tests/unit/test_ranking_service.py +++ b/backend/tests/unit/test_ranking_service.py @@ -6,7 +6,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker -from app.models import Experience, Quality, Role, Treatment, User, UserData +from app.models import Experience, FormStatus, Quality, Role, Treatment, User, UserData from app.schemas.user import UserRole from app.services.implementations.ranking_service import RankingService @@ -270,3 +270,26 @@ def test_save_preferences_validation(db_session: Session): ] with pytest.raises(ValueError): service.save_preferences(user_auth_id=user.auth_id, target="patient", items=dup_items) + + +def test_save_preferences_updates_status(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_submit", + has_blood_cancer="no", + caring_for_someone="no", + self_treatments=["Chemotherapy"], + ) + user.form_status = FormStatus.RANKING_TODO + db_session.commit() + service = RankingService(db_session) + + items = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 2, "scope": "self", "rank": 2}, + ] + + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=items) + + refreshed_user = db_session.query(User).filter(User.id == user.id).first() + assert refreshed_user.form_status == FormStatus.RANKING_SUBMITTED diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 05a525eb..6ecea9c2 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import sessionmaker from app.models import Role -from app.models.User import User +from app.models.User import FormStatus, User from app.schemas.user import ( SignUpMethod, UserCreateRequest, @@ -140,12 +140,14 @@ async def test_create_user_service(mock_firebase_auth, db_session): assert created_user.email == "test@example.com" assert created_user.role_id == 1 assert created_user.auth_id == "test_firebase_uid" + assert created_user.form_status == FormStatus.INTAKE_TODO # Assert database state db_user = db_session.query(User).filter_by(email="test@example.com").first() assert db_user is not None assert db_user.auth_id == "test_firebase_uid" assert db_user.role_id == 1 + assert db_user.form_status == FormStatus.INTAKE_TODO db_session.commit() # Commit successful test except Exception: @@ -177,12 +179,14 @@ async def test_create_user_with_google(mock_firebase_auth, db_session): assert created_user.email == "google@example.com" assert created_user.role_id == 1 assert created_user.auth_id == "test_firebase_uid" + assert created_user.form_status == FormStatus.INTAKE_TODO # Assert database state db_user = db_session.query(User).filter_by(email="google@example.com").first() assert db_user is not None assert db_user.auth_id == "test_firebase_uid" assert db_user.role_id == 1 + assert db_user.form_status == FormStatus.INTAKE_TODO db_session.commit() # Commit successful test except Exception: @@ -190,6 +194,33 @@ async def test_create_user_with_google(mock_firebase_auth, db_session): raise +@pytest.mark.asyncio +async def test_create_admin_user_sets_completed_status(mock_firebase_auth, db_session): + try: + user_service = UserService(db_session) + user_data = UserCreateRequest( + first_name="Admin", + last_name="User", + email="admin@example.com", + password="StrongPass@123", + role=UserRole.ADMIN, + signup_method=SignUpMethod.PASSWORD, + ) + + created_user = await user_service.create_user(user_data) + + assert created_user.form_status == FormStatus.COMPLETED + + db_user = db_session.query(User).filter_by(email="admin@example.com").first() + assert db_user is not None + assert db_user.form_status == FormStatus.COMPLETED + + db_session.commit() + except Exception: + db_session.rollback() + raise + + @pytest.mark.asyncio async def test_delete_user_by_email(db_session): """Test deleting a user by email""" @@ -516,6 +547,7 @@ async def test_update_user_by_id(db_session): first_name="Updated", last_name="Name", role=UserRole.ADMIN, # Update to ADMIN role + form_status=FormStatus.RANKING_TODO, ), ) @@ -524,10 +556,12 @@ async def test_update_user_by_id(db_session): assert updated_user.last_name == "Name" assert updated_user.role.name == "admin" # Compare role name string assert updated_user.email == "update@example.com" # Unchanged + assert updated_user.form_status == FormStatus.RANKING_TODO # Verify database state db_user = db_session.query(User).filter_by(id=test_user.id).first() assert db_user.role_id == 3 # ADMIN role ID + assert db_user.form_status == FormStatus.RANKING_TODO except Exception: db_session.rollback() diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index e9465c7f..9ad7cfcc 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -11,8 +11,12 @@ import { import AUTHENTICATED_USER_KEY from '../constants/AuthConstants'; import baseAPIClient from './baseAPIClient'; -import { getLocalStorageObjProperty, setLocalStorageObjProperty } from '../utils/LocalStorageUtils'; -import { signInWithEmailAndPassword, applyActionCode } from 'firebase/auth'; +import { + getLocalStorageObj, + getLocalStorageObjProperty, + setLocalStorageObjProperty, +} from '../utils/LocalStorageUtils'; +import { signInWithEmailAndPassword, applyActionCode, checkActionCode } from 'firebase/auth'; import { auth } from '@/config/firebase'; import { sendEmailVerificationToUser } from '@/services/firebaseAuthService'; @@ -31,7 +35,7 @@ export interface AuthResult { validationErrors?: string[]; } -const login = async (email: string, password: string): Promise => { +export const login = async (email: string, password: string): Promise => { try { // Validate inputs if (!validateEmail(email)) { @@ -63,7 +67,7 @@ const login = async (email: string, password: string): Promise => { withCredentials: true, }); localStorage.setItem(AUTHENTICATED_USER_KEY, JSON.stringify(data)); - return { success: true, user: { ...data.user, ...data } }; + return { success: true, user: { ...data.user, ...data } as AuthenticatedUser }; } catch { // Backend login failure is not critical since Firebase auth succeeded return { @@ -118,7 +122,7 @@ const login = async (email: string, password: string): Promise => { } }; -const logout = async (): Promise => { +export const logout = async (): Promise => { const bearerToken = `Bearer ${getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'accessToken')}`; try { @@ -132,19 +136,39 @@ const logout = async (): Promise => { }; // Get current authenticated user from localStorage -const getCurrentUser = (): AuthenticatedUser | null => { +export const getCurrentUser = (): AuthenticatedUser => { try { const userDataString = localStorage.getItem(AUTHENTICATED_USER_KEY); if (!userDataString) return null; const userData = JSON.parse(userDataString); - return userData; + if (userData?.user) { + return { ...userData.user, ...userData } as AuthenticatedUser; + } + return userData as AuthenticatedUser; } catch (error) { console.error('Error retrieving current user:', error); return null; } }; +export const syncCurrentUser = async (): Promise => { + try { + const { data } = await baseAPIClient.get('/auth/me'); + const stored = getLocalStorageObj>(AUTHENTICATED_USER_KEY) || {}; + const merged = { + ...stored, + user: data, + formStatus: data.formStatus, + }; + localStorage.setItem(AUTHENTICATED_USER_KEY, JSON.stringify(merged)); + return { ...(data as unknown as Record), ...merged } as AuthenticatedUser; + } catch (error) { + console.error('Failed to sync current user:', error); + return getCurrentUser(); + } +}; + export const register = async ({ first_name, last_name, @@ -250,7 +274,9 @@ export const register = async ({ } }; -const resetPassword = async (email: string): Promise<{ success: boolean; error?: string }> => { +export const resetPassword = async ( + email: string, +): Promise<{ success: boolean; error?: string }> => { try { if (!validateEmail(email)) { return { success: false, error: 'Please enter a valid email address' }; @@ -263,7 +289,7 @@ const resetPassword = async (email: string): Promise<{ success: boolean; error?: } }; -const verifyEmail = async (email: string): Promise => { +export const verifyEmail = async (email: string): Promise => { try { await baseAPIClient.post(`/auth/verify/${email}`, {}, { withCredentials: true }); return true; @@ -283,11 +309,19 @@ const verifyEmail = async (email: string): Promise => { } }; -const verifyEmailWithCode = async ( +export const verifyEmailWithCode = async ( oobCode: string, ): Promise<{ success: boolean; error?: string }> => { try { // Verify with Firebase + // Optional: validate action code before applying + try { + await checkActionCode(auth, oobCode); + } catch (codeError) { + console.error('[VERIFY_EMAIL] checkActionCode failed:', codeError); + throw codeError; + } + await applyActionCode(auth, oobCode); // Get the current user to get their email @@ -305,6 +339,7 @@ const verifyEmailWithCode = async ( return { success: true }; } catch (error) { + console.error('[VERIFY_EMAIL] Verification failed', error); if (error && typeof error === 'object' && 'code' in error) { const errorCode = (error as { code?: string }).code; if (errorCode === 'auth/invalid-action-code') { @@ -317,13 +352,24 @@ const verifyEmailWithCode = async ( success: false, error: 'Verification link has expired. Please request a new one.', }; + } else { + return { + success: false, + error: `Verification failed (${errorCode}). Please request a new link.`, + }; } } - return { success: false, error: 'Verification failed. Please try again.' }; + return { + success: false, + error: + error instanceof Error + ? `Verification failed: ${error.message}` + : 'Verification failed. Please try again.', + }; } }; -const refresh = async (): Promise => { +export const refresh = async (): Promise => { try { const refreshToken = getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'refreshToken'); @@ -340,5 +386,3 @@ const refresh = async (): Promise => { return false; } }; - -export { login, logout, getCurrentUser, resetPassword, verifyEmail, verifyEmailWithCode, refresh }; diff --git a/frontend/src/components/auth/FormStatusGuard.tsx b/frontend/src/components/auth/FormStatusGuard.tsx new file mode 100644 index 00000000..071cd3f3 --- /dev/null +++ b/frontend/src/components/auth/FormStatusGuard.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormStatus } from '@/types/authTypes'; +import { useFormStatusGuard } from '@/hooks/useFormStatusGuard'; + +interface FormStatusGuardProps { + allowedStatuses: FormStatus[]; + children: React.ReactNode; +} + +export const FormStatusGuard: React.FC = ({ allowedStatuses, children }) => { + const { loading, allowed } = useFormStatusGuard(allowedStatuses); + + if (loading) { + return null; + } + + if (!allowed) { + return null; + } + + return <>{children}; +}; diff --git a/frontend/src/constants/formStatusRoutes.ts b/frontend/src/constants/formStatusRoutes.ts new file mode 100644 index 00000000..b11318ae --- /dev/null +++ b/frontend/src/constants/formStatusRoutes.ts @@ -0,0 +1,44 @@ +import { FormStatus, UserRole } from '@/types/authTypes'; + +export type StatusRouteMap = Record; + +export const PARTICIPANT_STATUS_ROUTES: StatusRouteMap = { + [FormStatus.INTAKE_TODO]: '/welcome', + [FormStatus.INTAKE_SUBMITTED]: '/participant/intake/thank-you', + [FormStatus.RANKING_TODO]: '/participant/ranking', + [FormStatus.RANKING_SUBMITTED]: '/participant/ranking/thank-you', + [FormStatus.SECONDARY_APPLICATION_TODO]: '/participant/ranking', + [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/participant/ranking/thank-you', + [FormStatus.COMPLETED]: '/participant/dashboard', +}; + +export const VOLUNTEER_STATUS_ROUTES: StatusRouteMap = { + [FormStatus.INTAKE_TODO]: '/welcome', + [FormStatus.INTAKE_SUBMITTED]: '/volunteer/intake/thank-you', + [FormStatus.RANKING_TODO]: '/volunteer/intake/thank-you', + [FormStatus.RANKING_SUBMITTED]: '/volunteer/intake/thank-you', + [FormStatus.SECONDARY_APPLICATION_TODO]: '/volunteer/secondary-application', + [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/volunteer/secondary-application/thank-you', + [FormStatus.COMPLETED]: '/volunteer/dashboard', +}; + +export const ADMIN_STATUS_ROUTES: StatusRouteMap = { + [FormStatus.INTAKE_TODO]: '/admin', + [FormStatus.INTAKE_SUBMITTED]: '/admin', + [FormStatus.RANKING_TODO]: '/admin', + [FormStatus.RANKING_SUBMITTED]: '/admin', + [FormStatus.SECONDARY_APPLICATION_TODO]: '/admin', + [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/admin', + [FormStatus.COMPLETED]: '/admin', +}; + +export const ROLE_STATUS_ROUTES: Record = { + [UserRole.PARTICIPANT]: PARTICIPANT_STATUS_ROUTES, + [UserRole.VOLUNTEER]: VOLUNTEER_STATUS_ROUTES, + [UserRole.ADMIN]: ADMIN_STATUS_ROUTES, +}; + +export const getRedirectRoute = (role: UserRole, status: FormStatus): string => { + const roleRoutes = ROLE_STATUS_ROUTES[role]; + return roleRoutes?.[status] ?? '/welcome'; +}; diff --git a/frontend/src/hooks/useAuthGuard.ts b/frontend/src/hooks/useAuthGuard.ts index f08a5acc..b1bed153 100644 --- a/frontend/src/hooks/useAuthGuard.ts +++ b/frontend/src/hooks/useAuthGuard.ts @@ -4,6 +4,7 @@ import { onAuthStateChanged, User } from 'firebase/auth'; import { auth } from '@/config/firebase'; import { UserRole } from '@/types/authTypes'; import baseAPIClient from '@/APIClients/baseAPIClient'; +import { roleIdToUserRole } from '@/utils/roleUtils'; interface AxiosError { response?: { @@ -19,20 +20,6 @@ interface AuthGuardState { 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 diff --git a/frontend/src/hooks/useFormStatusGuard.ts b/frontend/src/hooks/useFormStatusGuard.ts new file mode 100644 index 00000000..508e99a7 --- /dev/null +++ b/frontend/src/hooks/useFormStatusGuard.ts @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getCurrentUser, syncCurrentUser } from '@/APIClients/authAPIClient'; +import { FormStatus, UserRole, AuthenticatedUser } from '@/types/authTypes'; +import { roleIdToUserRole } from '@/utils/roleUtils'; +import { getRedirectRoute } from '@/constants/formStatusRoutes'; + +interface GuardOptions { + allowAdminBypass?: boolean; +} + +interface GuardState { + loading: boolean; + allowed: boolean; +} + +const resolveStatus = (user: AuthenticatedUser): FormStatus | null => { + if (!user) { + return null; + } + const status = + (user.user?.formStatus as FormStatus | undefined) || + (user.formStatus as unknown as FormStatus | undefined); + return status ?? null; +}; + +const resolveRole = (user: AuthenticatedUser): UserRole | null => { + if (!user) { + return null; + } + const roleId = user.user?.roleId ?? (user.roleId as unknown as number | undefined); + return roleIdToUserRole(roleId ?? null); +}; + +export const useFormStatusGuard = ( + allowedStatuses: FormStatus[], + options: GuardOptions = {}, +): GuardState => { + const router = useRouter(); + const [state, setState] = useState({ loading: true, allowed: false }); + const allowAdminBypass = options.allowAdminBypass ?? true; + + useEffect(() => { + let cancelled = false; + + const evaluate = async () => { + const currentUser = getCurrentUser(); + if (!currentUser) { + if (!cancelled) { + setState({ loading: false, allowed: false }); + } + await router.replace('/'); + return; + } + + const syncedUser = await syncCurrentUser(); + if (cancelled) { + return; + } + + const role = resolveRole(syncedUser); + if (!role) { + setState({ loading: false, allowed: false }); + await router.replace('/'); + return; + } + + if (allowAdminBypass && role === UserRole.ADMIN) { + setState({ loading: false, allowed: true }); + return; + } + + const status = resolveStatus(syncedUser); + if (!status) { + setState({ loading: false, allowed: false }); + await router.replace('/welcome'); + return; + } + + if (allowedStatuses.includes(status)) { + setState({ loading: false, allowed: true }); + return; + } + + const destination = getRedirectRoute(role, status); + setState({ loading: true, allowed: false }); + if (router.asPath !== destination) { + await router.replace(destination); + } else { + setState({ loading: false, allowed: false }); + } + }; + + void evaluate(); + + return () => { + cancelled = true; + }; + }, [allowedStatuses, allowAdminBypass, router]); + + return state; +}; diff --git a/frontend/src/pages/action.tsx b/frontend/src/pages/action.tsx index a0591508..ea31b918 100644 --- a/frontend/src/pages/action.tsx +++ b/frontend/src/pages/action.tsx @@ -4,21 +4,30 @@ import { verifyEmailWithCode } from '@/APIClients/authAPIClient'; export default function ActionPage() { const router = useRouter(); - const { mode, oobCode } = router.query; const [isProcessing, setIsProcessing] = useState(true); const [error, setError] = useState(''); useEffect(() => { + if (!router.isReady) { + return; + } + const handleAction = async () => { - if (!mode || !oobCode) { + const queryMode = router.query.mode; + const queryCode = router.query.oobCode; + + const normalizedMode = Array.isArray(queryMode) ? queryMode[0] : queryMode; + const normalizedCode = Array.isArray(queryCode) ? queryCode[0] : queryCode; + + if (!normalizedMode || !normalizedCode) { setError('Invalid verification link'); setIsProcessing(false); return; } - if (mode === 'verifyEmail') { + if (normalizedMode === 'verifyEmail') { try { - const result = await verifyEmailWithCode(oobCode as string); + const result = await verifyEmailWithCode(normalizedCode); if (result.success) { router.replace(`/?verified=true&mode=verifyEmail`); @@ -30,8 +39,8 @@ export default function ActionPage() { setError('An error occurred during verification'); setIsProcessing(false); } - } else if (mode === 'resetPassword') { - const targetUrl = `/set-new-password?oobCode=${oobCode}`; + } else if (normalizedMode === 'resetPassword') { + const targetUrl = `/set-new-password?oobCode=${normalizedCode}`; if (router.asPath !== targetUrl) { router.replace(targetUrl); } @@ -41,10 +50,8 @@ export default function ActionPage() { } }; - if (mode && oobCode) { - handleAction(); - } - }, [mode, oobCode, router]); + handleAction(); + }, [router.isReady, router.query.mode, router.query.oobCode, router.asPath, router]); if (error) { return ( diff --git a/frontend/src/pages/participant/dashboard.tsx b/frontend/src/pages/participant/dashboard.tsx new file mode 100644 index 00000000..03fe29be --- /dev/null +++ b/frontend/src/pages/participant/dashboard.tsx @@ -0,0 +1,30 @@ +import { Box, Heading, Text, VStack } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function ParticipantDashboardPage() { + return ( + + + + + Participant dashboard coming soon + + Thanks for completing your forms. We're building the dashboard experience—stay + tuned for upcoming program updates. + + + + + + ); +} diff --git a/frontend/src/pages/participant/intake.tsx b/frontend/src/pages/participant/intake/index.tsx similarity index 74% rename from frontend/src/pages/participant/intake.tsx rename to frontend/src/pages/participant/intake/index.tsx index 8c87b23f..6d8fd31a 100644 --- a/frontend/src/pages/participant/intake.tsx +++ b/frontend/src/pages/participant/intake/index.tsx @@ -1,13 +1,14 @@ import React, { useMemo, useState } from 'react'; import { Box, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import baseAPIClient from '@/APIClients/baseAPIClient'; +import { syncCurrentUser } from '@/APIClients/authAPIClient'; import { PersonalInfoForm } from '@/components/intake/personal-info-form'; import { DemographicCancerForm, BasicDemographicsForm, } from '@/components/intake/demographic-cancer-form'; import { LovedOneForm } from '@/components/intake/loved-one-form'; -import { ThankYouScreen } from '@/components/intake/thank-you-screen'; import { COLORS, IntakeFormData, @@ -16,7 +17,8 @@ import { PersonalData, } from '@/constants/form'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; -import { UserRole } from '@/types/authTypes'; +import { FormStatus, UserRole } from '@/types/authTypes'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; // Import the component data types interface DemographicCancerFormData { @@ -50,6 +52,7 @@ interface BasicDemographicsFormData { } export default function ParticipantIntakePage() { + const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ ...INITIAL_INTAKE_FORM_DATA, @@ -92,6 +95,9 @@ export default function ParticipantIntakePage() { answers: updated, }); await baseAPIClient.post('/intake/submissions', { answers: updated }); + await syncCurrentUser(); + await router.replace('/participant/intake/thank-you'); + return; } finally { setSubmitting(false); } @@ -185,57 +191,50 @@ export default function ParticipantIntakePage() { }); }; - // If we're on thank you step, show the screen with form data - if (currentStepType === 'thank-you') { - 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/intake/thank-you.tsx b/frontend/src/pages/participant/intake/thank-you.tsx new file mode 100644 index 00000000..0095ffdf --- /dev/null +++ b/frontend/src/pages/participant/intake/thank-you.tsx @@ -0,0 +1,14 @@ +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { ThankYouScreen } from '@/components/intake/thank-you-screen'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function ParticipantIntakeThankYouPage() { + return ( + + + + + + ); +} diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking/index.tsx similarity index 86% rename from frontend/src/pages/participant/ranking.tsx rename to frontend/src/pages/participant/ranking/index.tsx index d8893c87..4d840228 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking/index.tsx @@ -1,6 +1,7 @@ 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 { Box, Flex, Text } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { UserIcon, WelcomeScreen } from '@/components/ui'; import { VolunteerMatchingForm, VolunteerRankingForm, @@ -11,9 +12,11 @@ import { } from '@/components/ranking'; import { COLORS } from '@/constants/form'; import baseAPIClient from '@/APIClients/baseAPIClient'; +import { syncCurrentUser } from '@/APIClients/authAPIClient'; import { auth } from '@/config/firebase'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; -import { UserRole } from '@/types/authTypes'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { FormStatus, UserRole } from '@/types/authTypes'; const RANKING_STATEMENTS = [ 'I would prefer a volunteer with the same age as me', @@ -63,6 +66,7 @@ export default function ParticipantRankingPage({ participantType = 'caregiver', caregiverHasCancer = true, }: ParticipantRankingPageProps) { + const router = useRouter(); const [derivedParticipantType, setDerivedParticipantType] = useState< 'cancerPatient' | 'caregiver' | null >(null); @@ -448,6 +452,9 @@ export default function ParticipantRankingPage({ try { await baseAPIClient.put('/ranking/preferences', items, { params: { target } }); setCurrentStep(nextStep); + await syncCurrentUser(); + await router.replace('/participant/ranking/thank-you'); + return; } catch (e) { console.error('Failed to save preferences', e); } @@ -485,102 +492,39 @@ export default function ParticipantRankingPage({ ); }; - 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 ; - } - })()} + + {participantType === 'caregiver' + ? (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + case 5: + return ; + default: + return ; + } + })() + : (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + case 4: + return ; + default: + return ; + } + })()} + ); } diff --git a/frontend/src/pages/participant/ranking/thank-you.tsx b/frontend/src/pages/participant/ranking/thank-you.tsx new file mode 100644 index 00000000..eca19e2c --- /dev/null +++ b/frontend/src/pages/participant/ranking/thank-you.tsx @@ -0,0 +1,68 @@ +import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { CheckMarkIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function ParticipantRankingThankYouPage() { + return ( + + + + + + + + + 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 + + . + + + + + + + ); +} diff --git a/frontend/src/pages/volunteer/dashboard.tsx b/frontend/src/pages/volunteer/dashboard.tsx new file mode 100644 index 00000000..9e608bc0 --- /dev/null +++ b/frontend/src/pages/volunteer/dashboard.tsx @@ -0,0 +1,30 @@ +import { Box, Heading, Text, VStack } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function VolunteerDashboardPage() { + return ( + + + + + Volunteer dashboard coming soon + + You've finished the application flow. Once the dashboard is ready, we'll + guide you through next steps from here. + + + + + + ); +} diff --git a/frontend/src/pages/volunteer/intake.tsx b/frontend/src/pages/volunteer/intake/index.tsx similarity index 76% rename from frontend/src/pages/volunteer/intake.tsx rename to frontend/src/pages/volunteer/intake/index.tsx index 1d8ddbf0..28c42c9e 100644 --- a/frontend/src/pages/volunteer/intake.tsx +++ b/frontend/src/pages/volunteer/intake/index.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Box, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import baseAPIClient from '@/APIClients/baseAPIClient'; +import { syncCurrentUser } from '@/APIClients/authAPIClient'; import { PersonalInfoForm } from '@/components/intake/personal-info-form'; import { DemographicCancerForm, BasicDemographicsForm, } from '@/components/intake/demographic-cancer-form'; import { LovedOneForm } from '@/components/intake/loved-one-form'; -import { ThankYouScreen } from '@/components/intake/thank-you-screen'; import { COLORS, IntakeFormData, @@ -16,7 +17,8 @@ import { PersonalData, } from '@/constants/form'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; -import { UserRole } from '@/types/authTypes'; +import { FormStatus, UserRole } from '@/types/authTypes'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; // Import the component data types interface DemographicCancerFormData { @@ -50,6 +52,7 @@ interface BasicDemographicsFormData { } export default function VolunteerIntakePage() { + const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ ...INITIAL_INTAKE_FORM_DATA, @@ -93,6 +96,9 @@ export default function VolunteerIntakePage() { setSubmitting(true); try { await baseAPIClient.post('/intake/submissions', { answers: updated }); + await syncCurrentUser(); + await router.replace('/volunteer/intake/thank-you'); + return; } catch (error: unknown) { // eslint-disable-next-line no-console const errorData = @@ -194,57 +200,50 @@ export default function VolunteerIntakePage() { }); }; - // If we're on thank you step, show the screen with form data - if (currentStepType === 'thank-you') { - 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/volunteer/intake/thank-you.tsx b/frontend/src/pages/volunteer/intake/thank-you.tsx new file mode 100644 index 00000000..3699b7c5 --- /dev/null +++ b/frontend/src/pages/volunteer/intake/thank-you.tsx @@ -0,0 +1,14 @@ +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { ThankYouScreen } from '@/components/intake/thank-you-screen'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function VolunteerIntakeThankYouPage() { + return ( + + + + + + ); +} diff --git a/frontend/src/pages/volunteer/secondary-application.tsx b/frontend/src/pages/volunteer/secondary-application/index.tsx similarity index 52% rename from frontend/src/pages/volunteer/secondary-application.tsx rename to frontend/src/pages/volunteer/secondary-application/index.tsx index 102d63e5..f07d3275 100644 --- a/frontend/src/pages/volunteer/secondary-application.tsx +++ b/frontend/src/pages/volunteer/secondary-application/index.tsx @@ -1,13 +1,17 @@ -import { UserIcon, WelcomeScreen, CheckMarkIcon } from '@/components/ui'; +import { UserIcon, WelcomeScreen } from '@/components/ui'; import { useState } from 'react'; +import { useRouter } from 'next/router'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; -import { UserRole } from '@/types/authTypes'; -import { Box, VStack, Heading, Text } from '@chakra-ui/react'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { FormStatus, UserRole } from '@/types/authTypes'; +import { Box } from '@chakra-ui/react'; import { COLORS } from '@/constants/form'; import { VolunteerProfileForm } from '@/components/intake/volunteer-profile-form'; import { VolunteerReferencesForm } from '@/components/intake/volunteer-references-form'; +import { syncCurrentUser } from '@/APIClients/authAPIClient'; export default function SecondaryApplicationPage() { + const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); const [profileData, setProfileData] = useState<{ experience: string }>({ experience: '' }); const [referencesData, setReferencesData] = useState<{ @@ -75,9 +79,11 @@ export default function SecondaryApplicationPage() { p={12} > { + onNext={async (data) => { setReferencesData(data); setCurrentStep(4); + await syncCurrentUser(); + await router.replace('/volunteer/secondary-application/thank-you'); }} onBack={() => setCurrentStep(2)} /> @@ -85,84 +91,22 @@ export default function SecondaryApplicationPage() { ); - const ThankYouScreen = () => ( - - - - - - - Success! - - - Thank you for sharing your references and experiences with us. - - - - We will reach out in the next 5-7 business days with the next steps. For immediate help, - please reach us at{' '} - - FirstConnections@lls.org - - . Please note LLSC's working days are Monday-Thursday. - - - - - ); - return ( - {(() => { - switch (currentStep) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; - } - })()} + + {(() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } + })()} + ); } diff --git a/frontend/src/pages/volunteer/secondary-application/thank-you.tsx b/frontend/src/pages/volunteer/secondary-application/thank-you.tsx new file mode 100644 index 00000000..07b186ff --- /dev/null +++ b/frontend/src/pages/volunteer/secondary-application/thank-you.tsx @@ -0,0 +1,67 @@ +import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { FormStatusGuard } from '@/components/auth/FormStatusGuard'; +import { CheckMarkIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; +import { FormStatus, UserRole } from '@/types/authTypes'; + +export default function VolunteerSecondaryApplicationThankYouPage() { + return ( + + + + + + + + + Success! + + + Thank you for sharing your references and experiences with us. + + + + We will reach out in the next 5-7 business days with the next steps. For immediate + help, please reach us at{' '} + + FirstConnections@lls.org + + . Please note LLSC's working days are Monday-Thursday. + + + + + + + ); +} diff --git a/frontend/src/pages/welcome.tsx b/frontend/src/pages/welcome.tsx index 6a36d7ec..7b8184ef 100644 --- a/frontend/src/pages/welcome.tsx +++ b/frontend/src/pages/welcome.tsx @@ -2,40 +2,87 @@ import Link from 'next/link'; import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; -import { getCurrentUser } from '@/APIClients/authAPIClient'; -import { AuthenticatedUser } from '@/types/authTypes'; +import { getCurrentUser, syncCurrentUser } from '@/APIClients/authAPIClient'; +import { AuthenticatedUser, FormStatus, UserRole } from '@/types/authTypes'; +import { roleIdToUserRole } from '@/utils/roleUtils'; +import { getRedirectRoute } from '@/constants/formStatusRoutes'; export default function WelcomePage() { const router = useRouter(); const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - // Check if user is logged in when component mounts - const user = getCurrentUser(); - setCurrentUser(user); - }, []); + const evaluate = async () => { + const stored = getCurrentUser(); + if (stored) { + setCurrentUser(stored); + } - const handleContinueInEnglish = () => { - // Cast to any to handle the nested user structure - const userData = currentUser as any; + try { + const synced = await syncCurrentUser(); + if (synced) { + setCurrentUser(synced); + const role = roleIdToUserRole(synced.user?.roleId ?? null); + const status = synced.user?.formStatus as FormStatus | undefined; - // Check if user exists and has a roleId - if (userData && userData.user && userData.user.roleId) { - // Check user role based on roleId - assuming 1=participant, 2=volunteer, 3=admin - if (userData.user.roleId === 1) { - router.push('/participant/intake'); - } else if (userData.user.roleId === 2) { - router.push('/volunteer/intake'); - } else { - router.push('/participant-form'); + if (role) { + if (role === UserRole.ADMIN) { + await router.replace('/admin'); + return; + } + + if (status && status !== FormStatus.INTAKE_TODO) { + const destination = getRedirectRoute(role, status); + if (destination !== router.asPath) { + await router.replace(destination); + return; + } + } + } + } + } catch (error) { + console.error('Failed to sync user on welcome page:', error); } + + setLoading(false); + }; + + void evaluate(); + }, [router]); + + const handleContinueInEnglish = () => { + const role = roleIdToUserRole(currentUser?.user?.roleId ?? null); + const status = currentUser?.user?.formStatus as FormStatus | undefined; + + if (!role || !status) { + router.push('/'); + return; + } + + if (role === UserRole.ADMIN) { + router.push('/admin'); + return; + } + + if (status !== FormStatus.INTAKE_TODO) { + router.push(getRedirectRoute(role, status)); + return; + } + + if (role === UserRole.PARTICIPANT) { + router.push('/participant/intake'); + } else if (role === UserRole.VOLUNTEER) { + router.push('/volunteer/intake'); } else { - console.log('No user logged in, routing to sign-in form'); - // If no user is logged in, redirect to signup/login router.push('/'); } }; + if (loading) { + return null; + } + return ( {/* Left: Content */} diff --git a/frontend/src/types/authTypes.ts b/frontend/src/types/authTypes.ts index fec7ea5a..c6d5cf65 100644 --- a/frontend/src/types/authTypes.ts +++ b/frontend/src/types/authTypes.ts @@ -11,6 +11,16 @@ export enum UserRole { ADMIN = 'admin', } +export enum FormStatus { + INTAKE_TODO = 'intake-todo', + INTAKE_SUBMITTED = 'intake-submitted', + RANKING_TODO = 'ranking-todo', + RANKING_SUBMITTED = 'ranking-submitted', + SECONDARY_APPLICATION_TODO = 'secondary-application-todo', + SECONDARY_APPLICATION_SUBMITTED = 'secondary-application-submitted', + COMPLETED = 'completed', +} + export interface UserBase { firstName: string; lastName: string; @@ -31,6 +41,7 @@ export interface UserCreateResponse { email: string; roleId: number; authId: string; + formStatus: FormStatus; } export interface LoginRequest { @@ -52,4 +63,4 @@ export interface AuthResponse extends Token { } // Type for an authenticated user in the system -export type AuthenticatedUser = (UserCreateResponse & Token) | null; +export type AuthenticatedUser = (UserCreateResponse & Token & { user?: UserCreateResponse }) | null; diff --git a/frontend/src/utils/roleUtils.ts b/frontend/src/utils/roleUtils.ts new file mode 100644 index 00000000..633a2949 --- /dev/null +++ b/frontend/src/utils/roleUtils.ts @@ -0,0 +1,14 @@ +import { UserRole } from '@/types/authTypes'; + +export const roleIdToUserRole = (roleId: number | null | undefined): UserRole | null => { + switch (roleId) { + case 1: + return UserRole.PARTICIPANT; + case 2: + return UserRole.VOLUNTEER; + case 3: + return UserRole.ADMIN; + default: + return null; + } +};